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

davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git


The following commit(s) were added to refs/heads/main by this push:
     new 92c353446b56 CAMEL-26449: camel-core - DefaultShutdownStrategy: a 
route stop that times out must not force or cut short the CamelContext stop 
(#28805)
92c353446b56 is described below

commit 92c353446b561a1956e263489d113bbd5a7bbe38
Author: allthingssecurity <[email protected]>
AuthorDate: Mon Nov 2 16:22:52 2026 +0530

    CAMEL-26449: camel-core - DefaultShutdownStrategy: a route stop that times 
out must not force or cut short the CamelContext stop (#28805)
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../org/apache/camel/impl/engine/DefaultRoute.java |   5 +-
 .../camel/impl/engine/DefaultShutdownStrategy.java |  85 +++--
 ...aultShutdownStrategyConcurrentShutdownTest.java | 420 +++++++++++++++++++++
 3 files changed, 482 insertions(+), 28 deletions(-)

diff --git 
a/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/DefaultRoute.java
 
b/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/DefaultRoute.java
index 03047b959c83..7156080c38a0 100644
--- 
a/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/DefaultRoute.java
+++ 
b/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/DefaultRoute.java
@@ -25,6 +25,7 @@ import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.ConcurrentMap;
+import java.util.concurrent.CopyOnWriteArrayList;
 
 import org.apache.camel.CamelContext;
 import org.apache.camel.Channel;
@@ -114,7 +115,9 @@ public class DefaultRoute extends ServiceSupport implements 
Route {
 
     private final Endpoint endpoint;
     private final Map<String, Object> properties = new HashMap<>();
-    private final List<Service> services = new ArrayList<>();
+    // copy-on-write, as the shutdown task of a graceful shutdown iterates the 
services of a route while another
+    // thread can shut the route down (a route stop that runs while the 
CamelContext is stopping clears them).
+    private final List<Service> services = new CopyOnWriteArrayList<>();
     private final List<Service> servicesToStop = new ArrayList<>();
     // the services added with addService, which are kept when the services 
are gathered again
     private final List<Service> addedServices = new ArrayList<>();
diff --git 
a/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/DefaultShutdownStrategy.java
 
b/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/DefaultShutdownStrategy.java
index dc45a9770d47..67cbe7bff753 100644
--- 
a/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/DefaultShutdownStrategy.java
+++ 
b/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/DefaultShutdownStrategy.java
@@ -34,6 +34,9 @@ import java.util.concurrent.Future;
 import java.util.concurrent.RejectedExecutionException;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantLock;
 
 import org.apache.camel.CamelContext;
 import org.apache.camel.CamelContextAware;
@@ -120,6 +123,8 @@ public class DefaultShutdownStrategy extends ServiceSupport 
implements ShutdownS
     private final CamelLogger logger = new CamelLogger(LOG, 
LoggingLevel.DEBUG);
 
     private CamelContext camelContext;
+    // guards the lazy creation of the executor, as several shutdowns can run 
at the same time
+    private final Lock executorLock = new ReentrantLock();
     private ExecutorService executor;
     private long timeout = 45;
     private TimeUnit timeUnit = TimeUnit.SECONDS;
@@ -130,7 +135,7 @@ public class DefaultShutdownStrategy extends ServiceSupport 
implements ShutdownS
 
     private boolean forceShutdown;
     private final AtomicBoolean timeoutOccurred = new AtomicBoolean();
-    private volatile Future<?> currentShutdownTaskFuture;
+    private final AtomicReference<Future<?>> currentShutdownTaskFuture = new 
AtomicReference<>();
 
     public DefaultShutdownStrategy() {
     }
@@ -207,32 +212,43 @@ public class DefaultShutdownStrategy extends 
ServiceSupport implements ShutdownS
             logger.log(msg);
         }
 
-        // use another thread to perform the shutdowns so we can support 
timeout
+        // use another thread to perform the shutdowns so we can support 
timeout.
+        // The future and the timeout flag of this shutdown are local: several 
shutdowns can run at the same time (a
+        // route is stopped or suspended while the CamelContext is stopping), 
and the timeout of one must not cancel,
+        // force or cut short the graceful shutdown of another. The fields are 
only set for the getters.
+        final AtomicBoolean shutdownTimeoutOccurred = new AtomicBoolean();
+        Future<?> future = null;
+        boolean interrupted = false;
         timeoutOccurred.set(false);
         try {
-            currentShutdownTaskFuture = getExecutorService().submit(new 
ShutdownTask(
+            future = getExecutorService().submit(new ShutdownTask(
                     context, routesOrdered, timeout, timeUnit, suspendOnly,
-                    abortAfterTimeout, timeoutOccurred, 
isLogInflightExchangesOnTimeout()));
-            currentShutdownTaskFuture.get(timeout, timeUnit);
+                    abortAfterTimeout, shutdownTimeoutOccurred, 
isLogInflightExchangesOnTimeout()));
+            currentShutdownTaskFuture.set(future);
+            future.get(timeout, timeUnit);
         } catch (RejectedExecutionException e) {
             // the task was rejected
         } catch (ExecutionException e) {
             // unwrap execution exception
             throw 
RuntimeCamelException.wrapRuntimeCamelException(e.getCause());
         } catch (Exception e) {
-            // either timeout or interrupted exception was thrown so this is 
okay
-            // as interrupted would mean cancel was called on the 
currentShutdownTaskFuture to signal a forced timeout
+            // the timeout of this shutdown elapsed, or the calling thread was 
interrupted (handled as a timeout)
+            interrupted = e instanceof InterruptedException;
 
             // we hit a timeout, so set the flag
+            shutdownTimeoutOccurred.set(true);
             timeoutOccurred.set(true);
 
             // timeout then cancel the task
-            if (currentShutdownTaskFuture != null) {
-                currentShutdownTaskFuture.cancel(true);
+            if (future != null) {
+                future.cancel(true);
             }
 
-            // signal we are forcing shutdown now, since timeout occurred
-            this.forceShutdown = forceShutdown;
+            // signal we are forcing shutdown now, since timeout occurred 
(another shutdown that times out, and
+            // is not forced, must not turn it off again)
+            if (forceShutdown) {
+                this.forceShutdown = true;
+            }
 
             // if set, stop processing and return false to indicate that the 
shutdown is aborting
             if (!forceShutdown && abortAfterTimeout) {
@@ -240,7 +256,7 @@ public class DefaultShutdownStrategy extends ServiceSupport 
implements ShutdownS
                          + " Notice: some resources may still be running as 
graceful shutdown did not complete successfully.");
 
                 // we attempt to force shutdown so lets log the current 
inflight exchanges which are affected
-                logInflightExchanges(context, routes, 
isLogInflightExchangesOnTimeout());
+                logInflightExchanges(context, routesOrdered, 
isLogInflightExchangesOnTimeout());
 
                 return false;
             } else {
@@ -249,13 +265,13 @@ public class DefaultShutdownStrategy extends 
ServiceSupport implements ShutdownS
                              + " Notice: some resources may still be running 
as graceful shutdown did not complete successfully.");
 
                     // we attempt to force shutdown so lets log the current 
inflight exchanges which are affected
-                    logInflightExchanges(context, routes, 
isLogInflightExchangesOnTimeout());
+                    logInflightExchanges(context, routesOrdered, 
isLogInflightExchangesOnTimeout());
 
                     // force the routes to shutdown now
                     shutdownRoutesNow(routesOrdered);
 
                     // now the route consumers has been shutdown, then prepare 
route services for shutdown now (forced)
-                    for (RouteStartupOrder order : routes) {
+                    for (RouteStartupOrder order : routesOrdered) {
                         for (Service service : order.getServices()) {
                             prepareShutdown(service, false, true, true, 
isSuppressLoggingOnTimeout());
                         }
@@ -264,11 +280,16 @@ public class DefaultShutdownStrategy extends 
ServiceSupport implements ShutdownS
                     LOG.warn("Timeout occurred during graceful shutdown. Will 
ignore shutting down the remainder routes."
                              + " Notice: some resources may still be running 
as graceful shutdown did not complete successfully.");
 
-                    logInflightExchanges(context, routes, 
isLogInflightExchangesOnTimeout());
+                    logInflightExchanges(context, routesOrdered, 
isLogInflightExchangesOnTimeout());
                 }
             }
         } finally {
-            currentShutdownTaskFuture = null;
+            // only clear the future of this shutdown, not the one of another 
shutdown that runs meanwhile
+            currentShutdownTaskFuture.compareAndSet(future, null);
+            if (interrupted) {
+                // restore the interrupt flag, now that the routes have been 
shutdown (forced) or the shutdown aborted
+                Thread.currentThread().interrupt();
+            }
         }
 
         if (logger.shouldLog()) {
@@ -377,12 +398,12 @@ public class DefaultShutdownStrategy extends 
ServiceSupport implements ShutdownS
     }
 
     /**
-     * Future for the current shutdown task, when a task is in progress.
+     * Future for the task of the shutdown that was started last, while that 
shutdown is in progress, or <tt>null</tt>.
      * <p/>
      * Important: This API is only for advanced use-cases.
      */
     public Future<?> getCurrentShutdownTaskFuture() {
-        return currentShutdownTaskFuture;
+        return currentShutdownTaskFuture.get();
     }
 
     /**
@@ -464,11 +485,16 @@ public class DefaultShutdownStrategy extends 
ServiceSupport implements ShutdownS
     }
 
     private ExecutorService getExecutorService() {
-        if (executor == null) {
-            // use a thread pool that allow to terminate idle threads so they 
do not hang around forever
-            executor = 
camelContext.getExecutorServiceManager().newSingleThreadExecutor(this, 
"ShutdownTask");
+        executorLock.lock();
+        try {
+            if (executor == null) {
+                // use a thread pool that allow to terminate idle threads so 
they do not hang around forever
+                executor = 
camelContext.getExecutorServiceManager().newSingleThreadExecutor(this, 
"ShutdownTask");
+            }
+            return executor;
+        } finally {
+            executorLock.unlock();
         }
-        return executor;
     }
 
     @Override
@@ -481,11 +507,16 @@ public class DefaultShutdownStrategy extends 
ServiceSupport implements ShutdownS
 
     @Override
     protected void doShutdown() throws Exception {
-        if (executor != null) {
-            // force shutting down as we are shutting down Camel
-            camelContext.getExecutorServiceManager().shutdownNow(executor);
-            // should clear executor so we can restart by creating a new 
thread pool
-            executor = null;
+        executorLock.lock();
+        try {
+            if (executor != null) {
+                // force shutting down as we are shutting down Camel
+                camelContext.getExecutorServiceManager().shutdownNow(executor);
+                // should clear executor so we can restart by creating a new 
thread pool
+                executor = null;
+            }
+        } finally {
+            executorLock.unlock();
         }
     }
 
diff --git 
a/core/camel-core/src/test/java/org/apache/camel/impl/engine/DefaultShutdownStrategyConcurrentShutdownTest.java
 
b/core/camel-core/src/test/java/org/apache/camel/impl/engine/DefaultShutdownStrategyConcurrentShutdownTest.java
new file mode 100644
index 000000000000..787e761d9bbc
--- /dev/null
+++ 
b/core/camel-core/src/test/java/org/apache/camel/impl/engine/DefaultShutdownStrategyConcurrentShutdownTest.java
@@ -0,0 +1,420 @@
+/*
+ * 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.camel.impl.engine;
+
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.apache.camel.Consumer;
+import org.apache.camel.ContextTestSupport;
+import org.apache.camel.Endpoint;
+import org.apache.camel.Exchange;
+import org.apache.camel.Processor;
+import org.apache.camel.Producer;
+import org.apache.camel.ServiceStatus;
+import org.apache.camel.ShutdownRunningTask;
+import org.apache.camel.StatefulService;
+import org.apache.camel.Suspendable;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.spi.ShutdownAware;
+import org.apache.camel.support.DefaultComponent;
+import org.apache.camel.support.DefaultConsumer;
+import org.apache.camel.support.DefaultEndpoint;
+import org.apache.camel.support.service.ServiceSupport;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+import static org.awaitility.Awaitility.await;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+/**
+ * A route is stopped (with a short timeout) while the CamelContext is stopped 
(with a long timeout), in either order.
+ * The timeout of the route stop must not cancel, force or cut short the 
graceful shutdown of the CamelContext: the
+ * CamelContext must wait for the exchange that is still in flight on another 
route, and not stop its consumer under it.
+ * Also when the route stop shuts its route down while the CamelContext stop 
iterates the services of that route. And a
+ * route stop that times out after a forced CamelContext stop must not turn 
the forced shutdown off.
+ */
+class DefaultShutdownStrategyConcurrentShutdownTest extends ContextTestSupport 
{
+
+    private final CountDownLatch releaseA = new CountDownLatch(1);
+    private final CountDownLatch releaseB = new CountDownLatch(1);
+    // holds the task of the CamelContext stop while it iterates the services 
of route a
+    private final AtomicBoolean holdPendingCheck = new AtomicBoolean();
+    private final CountDownLatch inPendingCheck = new CountDownLatch(1);
+    private final CountDownLatch releasePendingCheck = new CountDownLatch(1);
+    // holds the CamelContext stop while it prepares route a for a forced 
shutdown
+    private final AtomicBoolean holdForcedPrepare = new AtomicBoolean();
+    private final CountDownLatch inForcedPrepare = new CountDownLatch(1);
+    private final CountDownLatch releaseForcedPrepare = new CountDownLatch(1);
+
+    @AfterEach
+    void releaseExchanges() {
+        releaseA.countDown();
+        releaseB.countDown();
+        releasePendingCheck.countDown();
+        releaseForcedPrepare.countDown();
+    }
+
+    @Test
+    void testRouteStopTimeoutDoesNotForceContextStop() throws Exception {
+        DefaultShutdownStrategy strategy = (DefaultShutdownStrategy) 
context.getShutdownStrategy();
+        strategy.setTimeout(60);
+
+        StatefulService consumerB = (StatefulService) 
context.getRoute("b").getConsumer();
+
+        // an exchange in flight on each route
+        CompletableFuture<Exchange> a = consume("a", "A");
+        CompletableFuture<Exchange> b = consume("b", "B");
+        await().atMost(10, TimeUnit.SECONDS).until(() -> 
context.getInflightRepository().size("a") == 1
+                && context.getInflightRepository().size("b") == 1);
+
+        // stop route a with a short timeout: its graceful shutdown waits for 
the exchange on route a
+        AtomicReference<Throwable> stopRouteFailure = new AtomicReference<>();
+        Thread stopRoute = new Thread(() -> {
+            try {
+                context.getRouteController().stopRoute("a", 5, 
TimeUnit.SECONDS);
+            } catch (Throwable e) {
+                stopRouteFailure.set(e);
+            }
+        }, "stop-route-a");
+        stopRoute.start();
+        await().atMost(10, TimeUnit.SECONDS).until(() -> 
strategy.getCurrentShutdownTaskFuture() != null);
+        Future<?> routeTask = strategy.getCurrentShutdownTaskFuture();
+
+        // meanwhile the CamelContext is stopped (60 seconds timeout)
+        AtomicReference<Throwable> stopContextFailure = new 
AtomicReference<>();
+        Thread stopContext = new Thread(() -> {
+            try {
+                context.stop();
+            } catch (Throwable e) {
+                stopContextFailure.set(e);
+            }
+        }, "stop-context");
+        stopContext.start();
+        await().atMost(10, TimeUnit.SECONDS).until(() -> 
strategy.getCurrentShutdownTaskFuture() != null
+                && strategy.getCurrentShutdownTaskFuture() != routeTask);
+        // the CamelContext stop has started before the route stop times out
+        assertTrue(stopRoute.isAlive(), "the route stop timed out before the 
CamelContext stop started");
+
+        // the route stop times out (its exchange is still in flight) and 
forces route a
+        stopRoute.join(20_000);
+        assertFalse(stopRoute.isAlive(), "the route stop did not complete");
+        assertNull(stopRouteFailure.get());
+        releaseA.countDown();
+
+        // the CamelContext stop waits for the exchange on route b: its 
consumer is suspended, not stopped under it
+        await("the CamelContext stop suspends route b").atMost(20, 
TimeUnit.SECONDS)
+                .until(() -> consumerB.getStatus() == ServiceStatus.Suspended 
|| consumerB.isStopping()
+                        || consumerB.isStopped());
+        if (consumerB.isStopping() || consumerB.isStopped()) {
+            fail("The consumer of route b was stopped while its exchange was 
in flight (status "
+                 + consumerB.getStatus() + ", force shutdown " + 
strategy.isForceShutdown()
+                 + "): the timeout of the route stop forced the graceful 
shutdown of the CamelContext");
+        }
+        assertEquals(1, context.getInflightRepository().size("b"));
+
+        // once the exchange completes the CamelContext stops, gracefully
+        releaseB.countDown();
+        stopContext.join(30_000);
+        assertFalse(stopContext.isAlive(), "the CamelContext stop did not 
complete");
+        assertNull(stopContextFailure.get());
+        assertTrue(context.isStopped());
+        Exchange exchangeB = b.get(10, TimeUnit.SECONDS);
+        assertNull(exchangeB.getException());
+        assertEquals("Done B", exchangeB.getMessage().getBody());
+        assertFalse(strategy.isForceShutdown());
+        assertTrue(a.isDone());
+    }
+
+    @Test
+    void testRouteStopTimeoutDoesNotCutShortContextStop() throws Exception {
+        DefaultShutdownStrategy strategy = (DefaultShutdownStrategy) 
context.getShutdownStrategy();
+        strategy.setTimeout(60);
+        StatefulService consumerB = (StatefulService) 
context.getRoute("b").getConsumer();
+
+        // an exchange in flight on each route
+        CompletableFuture<Exchange> a = consume("a", "A");
+        CompletableFuture<Exchange> b = consume("b", "B");
+        await().atMost(10, TimeUnit.SECONDS).until(() -> 
context.getInflightRepository().size("a") == 1
+                && context.getInflightRepository().size("b") == 1);
+
+        // the CamelContext is stopped (60 seconds timeout), and waits for the 
exchanges in flight
+        AtomicReference<Throwable> stopContextFailure = new 
AtomicReference<>();
+        Thread stopContext = new Thread(() -> {
+            try {
+                context.stop();
+            } catch (Throwable e) {
+                stopContextFailure.set(e);
+            }
+        }, "stop-context");
+        stopContext.start();
+        await().atMost(10, TimeUnit.SECONDS).until(() -> consumerB.getStatus() 
== ServiceStatus.Suspended);
+
+        // meanwhile route a is stopped with a short timeout, which times out 
(its task waits for the one of the
+        // CamelContext stop)
+        AtomicReference<Throwable> stopRouteFailure = new AtomicReference<>();
+        Thread stopRoute = new Thread(() -> {
+            try {
+                context.getRouteController().stopRoute("a", 1, 
TimeUnit.SECONDS);
+            } catch (Throwable e) {
+                stopRouteFailure.set(e);
+            }
+        }, "stop-route-a");
+        stopRoute.start();
+        stopRoute.join(20_000);
+        assertFalse(stopRoute.isAlive(), "the route stop did not complete");
+        assertNull(stopRouteFailure.get());
+        assertTrue(strategy.isTimeoutOccurred(), "the route stop did not time 
out");
+
+        // the CamelContext stop keeps waiting for the exchange on route b 
(its wait loop checks every second)
+        await("the CamelContext stop keeps waiting for route b").during(3, 
TimeUnit.SECONDS).atMost(5, TimeUnit.SECONDS)
+                .until(() -> consumerB.getStatus() == ServiceStatus.Suspended 
&& stopContext.isAlive());
+        assertEquals(1, context.getInflightRepository().size("b"));
+        assertFalse(strategy.isForceShutdown());
+
+        // once the exchanges complete the CamelContext stops, gracefully
+        releaseA.countDown();
+        releaseB.countDown();
+        stopContext.join(30_000);
+        assertFalse(stopContext.isAlive(), "the CamelContext stop did not 
complete");
+        assertNull(stopContextFailure.get());
+        Exchange exchangeB = b.get(10, TimeUnit.SECONDS);
+        assertNull(exchangeB.getException());
+        assertEquals("Done B", exchangeB.getMessage().getBody());
+        assertTrue(a.isDone());
+    }
+
+    @Test
+    void testRouteShutdownDuringContextStopDoesNotFailItsWait() throws 
Exception {
+        DefaultShutdownStrategy strategy = (DefaultShutdownStrategy) 
context.getShutdownStrategy();
+        strategy.setTimeout(60);
+        StatefulService consumerB = (StatefulService) 
context.getRoute("b").getConsumer();
+        CompletableFuture<Exchange> b = consume("b", "B");
+        await().atMost(10, TimeUnit.SECONDS).until(() -> 
context.getInflightRepository().size("b") == 1);
+
+        // the task of the CamelContext stop is held while it iterates the 
services of route a (to count their pending
+        // exchanges)
+        holdPendingCheck.set(true);
+        AtomicReference<Throwable> stopContextFailure = new 
AtomicReference<>();
+        Thread stopContext = new Thread(() -> {
+            try {
+                context.stop();
+            } catch (Throwable e) {
+                stopContextFailure.set(e);
+            }
+        }, "stop-context");
+        stopContext.start();
+        assertTrue(inPendingCheck.await(10, TimeUnit.SECONDS), "the 
CamelContext stop did not check route a");
+
+        // meanwhile route a is stopped: its task waits behind the one of the 
CamelContext stop, so it times out, and
+        // as the CamelContext is stopping it shuts route a down, which clears 
the services of the route
+        AtomicReference<Throwable> stopRouteFailure = new AtomicReference<>();
+        Thread stopRoute = new Thread(() -> {
+            try {
+                context.getRouteController().stopRoute("a", 1, 
TimeUnit.SECONDS);
+            } catch (Throwable e) {
+                stopRouteFailure.set(e);
+            }
+        }, "stop-route-a");
+        stopRoute.start();
+        stopRoute.join(20_000);
+        assertFalse(stopRoute.isAlive(), "the route stop did not complete");
+        assertNull(stopRouteFailure.get());
+        assertTrue(context.getRoute("a").getServices().isEmpty(), "the route 
stop did not shut down route a");
+        assertFalse(strategy.isForceShutdown());
+
+        // the task of the CamelContext stop goes on iterating, and keeps 
waiting for the exchange on route b
+        releasePendingCheck.countDown();
+        await("the CamelContext stop keeps waiting for route b").during(3, 
TimeUnit.SECONDS).atMost(5, TimeUnit.SECONDS)
+                .until(() -> consumerB.getStatus() == ServiceStatus.Suspended 
&& stopContext.isAlive());
+        assertEquals(1, context.getInflightRepository().size("b"));
+
+        releaseB.countDown();
+        stopContext.join(30_000);
+        assertFalse(stopContext.isAlive(), "the CamelContext stop did not 
complete");
+        assertNull(stopContextFailure.get());
+        Exchange exchangeB = b.get(10, TimeUnit.SECONDS);
+        assertNull(exchangeB.getException());
+        assertEquals("Done B", exchangeB.getMessage().getBody());
+    }
+
+    @Test
+    void testRouteStopTimeoutKeepsForcedContextStop() throws Exception {
+        DefaultShutdownStrategy strategy = (DefaultShutdownStrategy) 
context.getShutdownStrategy();
+        // the CamelContext stop times out (1 second); the route stop (60 
seconds) is cut short by an interrupt, which is
+        // handled as a timeout, once the CamelContext stop is forcing the 
routes: no race between the two timeouts
+        strategy.setTimeout(1);
+        holdForcedPrepare.set(true);
+
+        CompletableFuture<Exchange> a = consume("a", "A");
+        CompletableFuture<Exchange> b = consume("b", "B");
+        await().atMost(10, TimeUnit.SECONDS).until(() -> 
context.getInflightRepository().size("a") == 1
+                && context.getInflightRepository().size("b") == 1);
+
+        AtomicReference<Throwable> stopRouteFailure = new AtomicReference<>();
+        AtomicBoolean stopRouteInterrupted = new AtomicBoolean();
+        Thread stopRoute = new Thread(() -> {
+            try {
+                context.getRouteController().stopRoute("a", 60, 
TimeUnit.SECONDS);
+                
stopRouteInterrupted.set(Thread.currentThread().isInterrupted());
+            } catch (Throwable e) {
+                stopRouteFailure.set(e);
+            }
+        }, "stop-route-a");
+        stopRoute.start();
+        await().atMost(10, TimeUnit.SECONDS).until(() -> 
strategy.getCurrentShutdownTaskFuture() != null);
+
+        AtomicReference<Throwable> stopContextFailure = new 
AtomicReference<>();
+        Thread stopContext = new Thread(() -> {
+            try {
+                context.stop();
+            } catch (Throwable e) {
+                stopContextFailure.set(e);
+            }
+        }, "stop-context");
+        stopContext.start();
+
+        // the CamelContext stop times out and forces the routes to shut down; 
it is held while it prepares route a
+        assertTrue(inForcedPrepare.await(20, TimeUnit.SECONDS), "the 
CamelContext stop did not force route a");
+        assertTrue(strategy.isForceShutdown());
+        assertTrue(stopRoute.isAlive(), "the route stop timed out before the 
CamelContext stop");
+
+        // then the route stop times out (interrupted), which must not turn 
the forced shutdown off again
+        stopRoute.interrupt();
+        stopRoute.join(20_000);
+        assertFalse(stopRoute.isAlive(), "the route stop did not complete");
+        assertNull(stopRouteFailure.get());
+        assertTrue(stopRouteInterrupted.get(), "the route stop did not restore 
the interrupt flag");
+        assertTrue(strategy.isForceShutdown(), "the route stop turned off the 
forced shutdown of the CamelContext");
+
+        releaseForcedPrepare.countDown();
+        releaseA.countDown();
+        releaseB.countDown();
+        stopContext.join(30_000);
+        assertFalse(stopContext.isAlive(), "the CamelContext stop did not 
complete");
+        assertNull(stopContextFailure.get());
+        assertNotNull(a.get(10, TimeUnit.SECONDS));
+        assertNotNull(b.get(10, TimeUnit.SECONDS));
+    }
+
+    /**
+     * The consumer of the route receives a message (in another thread).
+     */
+    private CompletableFuture<Exchange> consume(String routeId, String body) {
+        DefaultConsumer consumer = (DefaultConsumer) 
context.getRoute(routeId).getConsumer();
+        Exchange exchange = consumer.createExchange(false);
+        exchange.getMessage().setBody(body);
+        CompletableFuture<Exchange> answer = new CompletableFuture<>();
+        Thread t = new Thread(
+                () -> consumer.getAsyncProcessor().process(exchange, doneSync 
-> answer.complete(exchange)),
+                "consumer-" + routeId);
+        t.start();
+        return answer;
+    }
+
+    @Override
+    protected RouteBuilder createRouteBuilder() {
+        return new RouteBuilder() {
+            @Override
+            public void configure() {
+                // a consumer that is suspended (not stopped) during a 
graceful shutdown
+                context.addComponent("suspendable", new DefaultComponent() {
+                    @Override
+                    protected Endpoint createEndpoint(String uri, String 
remaining, Map<String, Object> parameters) {
+                        return new DefaultEndpoint(uri, this) {
+                            @Override
+                            public Producer createProducer() {
+                                throw new UnsupportedOperationException();
+                            }
+
+                            @Override
+                            public Consumer createConsumer(Processor 
processor) {
+                                return new SuspendableConsumer(this, 
processor);
+                            }
+                        };
+                    }
+                });
+
+                from("suspendable:a").routeId("a")
+                        .process(new PendingCheck())
+                        .process(e -> assertTrue(releaseA.await(60, 
TimeUnit.SECONDS)))
+                        .setBody(simple("Done ${body}"));
+                from("suspendable:b").routeId("b")
+                        .process(e -> assertTrue(releaseB.await(60, 
TimeUnit.SECONDS)))
+                        .setBody(simple("Done ${body}"));
+            }
+        };
+    }
+
+    /**
+     * A service of route a: it can hold the task of the CamelContext stop 
while that counts the pending exchanges of
+     * route a, or the CamelContext stop while that prepares route a for a 
forced shutdown.
+     */
+    private final class PendingCheck extends ServiceSupport implements 
Processor, ShutdownAware {
+        @Override
+        public void process(Exchange exchange) {
+            // noop
+        }
+
+        @Override
+        public boolean deferShutdown(ShutdownRunningTask shutdownRunningTask) {
+            return false;
+        }
+
+        @Override
+        public int getPendingExchangesSize() {
+            if (Thread.currentThread().getName().contains("ShutdownTask") && 
holdPendingCheck.compareAndSet(true, false)) {
+                inPendingCheck.countDown();
+                try {
+                    assertTrue(releasePendingCheck.await(30, 
TimeUnit.SECONDS));
+                } catch (InterruptedException e) {
+                    Thread.currentThread().interrupt();
+                }
+            }
+            return 0;
+        }
+
+        @Override
+        public void prepareShutdown(boolean suspendOnly, boolean forced) {
+            if (forced && 
"stop-context".equals(Thread.currentThread().getName())
+                    && holdForcedPrepare.compareAndSet(true, false)) {
+                inForcedPrepare.countDown();
+                try {
+                    assertTrue(releaseForcedPrepare.await(30, 
TimeUnit.SECONDS));
+                } catch (InterruptedException e) {
+                    Thread.currentThread().interrupt();
+                }
+            }
+        }
+    }
+
+    private static final class SuspendableConsumer extends DefaultConsumer 
implements Suspendable {
+        SuspendableConsumer(Endpoint endpoint, Processor processor) {
+            super(endpoint, processor);
+        }
+    }
+}

Reply via email to