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 6469c548d6f7 CAMEL-25823: camel-core - Start Split and Multicast 
output producers when the route starts (#28147)
6469c548d6f7 is described below

commit 6469c548d6f797fe441688e553273a5ae5c2e175
Author: Guillaume Nodet <[email protected]>
AuthorDate: Tue Nov 3 20:06:38 2026 +0100

    CAMEL-25823: camel-core - Start Split and Multicast output producers when 
the route starts (#28147)
    
    Co-authored-by: Claude Sonnet 4.6 <[email protected]>
---
 .../apache/camel/processor/MulticastProcessor.java |  50 +++-
 .../errorhandler/RedeliveryErrorHandler.java       |  23 ++
 ...pervisingRouteControllerMulticastSplitTest.java | 195 ++++++++++++++++
 .../RouteStopCutOffCallerRouteTest.java            | 169 ++++++++++++++
 .../errorhandler/RouteStopCutOffLogNestedTest.java | 253 +++++++++++++++++++++
 .../errorhandler/RouteStopCutOffLogTest.java       | 116 +++++++++-
 .../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc    |  24 ++
 7 files changed, 825 insertions(+), 5 deletions(-)

diff --git 
a/core/camel-core-processor/src/main/java/org/apache/camel/processor/MulticastProcessor.java
 
b/core/camel-core-processor/src/main/java/org/apache/camel/processor/MulticastProcessor.java
index 7966de01cc42..71a8665f4595 100644
--- 
a/core/camel-core-processor/src/main/java/org/apache/camel/processor/MulticastProcessor.java
+++ 
b/core/camel-core-processor/src/main/java/org/apache/camel/processor/MulticastProcessor.java
@@ -55,6 +55,7 @@ import org.apache.camel.Traceable;
 import org.apache.camel.processor.aggregate.ShareUnitOfWorkAggregationStrategy;
 import org.apache.camel.processor.aggregate.UseOriginalAggregationStrategy;
 import org.apache.camel.processor.errorhandler.ErrorHandlerSupport;
+import org.apache.camel.processor.errorhandler.RedeliveryErrorHandler;
 import org.apache.camel.spi.AsyncProcessorAwaitManager;
 import org.apache.camel.spi.ErrorHandlerAware;
 import org.apache.camel.spi.IdAware;
@@ -705,6 +706,18 @@ public class MulticastProcessor extends 
BaseProcessorSupport
                             return;
                         }
 
+                        // Propagate the interrupted flag and the 
cut-off-WARN-logged marker from the
+                        // sub-exchange to the original exchange immediately, 
before the outer error handler's
+                        // callback fires. Only propagate when a stopped error 
handler in a nested EIP
+                        // (e.g. split inside multicast) already logged the 
cut-off WARN -- identified by
+                        // the CUTOFF_WARN_LOGGED property set by 
RedeliveryErrorHandler.runNotAllowed().
+                        // If interrupted was set by DirectProducer without 
logging a WARN, we must NOT
+                        // propagate: the outer error handler must still log 
its WARN for this cut-off.
+                        if 
(exchange.getProperty(RedeliveryErrorHandler.CUTOFF_WARN_LOGGED, false, 
Boolean.class)) {
+                            
original.getExchangeExtension().setInterrupted(true);
+                            
original.setProperty(RedeliveryErrorHandler.CUTOFF_WARN_LOGGED, Boolean.TRUE);
+                        }
+
                         exchangeResult.accept(exchange);
 
                         // aggregate exchanges if any
@@ -834,6 +847,12 @@ public class MulticastProcessor extends 
BaseProcessorSupport
                     // so we should set the failed exchange as the result and 
be done
                     result.set(exchange);
                 }
+                // Propagate the interrupted flag and the cut-off-WARN-logged 
marker from the
+                // sub-exchange to the original before doDone() fires.
+                if 
(exchange.getProperty(RedeliveryErrorHandler.CUTOFF_WARN_LOGGED, false, 
Boolean.class)) {
+                    original.getExchangeExtension().setInterrupted(true);
+                    
original.setProperty(RedeliveryErrorHandler.CUTOFF_WARN_LOGGED, Boolean.TRUE);
+                }
                 // and do the done work
                 doDone(exchange, true);
                 return false;
@@ -998,6 +1017,15 @@ public class MulticastProcessor extends 
BaseProcessorSupport
                     original.setProperty(ExchangePropertyKey.CORRELATION_ID, 
correlationId);
                 }
             }
+            // Propagate the interrupted flag and the cut-off-WARN-logged 
marker from the sub-exchange to
+            // the original. Only propagate when a stopped error handler in a 
nested EIP already logged the
+            // cut-off WARN (identified by CUTOFF_WARN_LOGGED set by 
RedeliveryErrorHandler.runNotAllowed()).
+            // DirectProducer sets interrupted without logging, so its 
interruption must NOT be forwarded here
+            // -- the outer handler must log the WARN for this cut-off.
+            if 
(subExchange.getProperty(RedeliveryErrorHandler.CUTOFF_WARN_LOGGED, false, 
Boolean.class)) {
+                original.getExchangeExtension().setInterrupted(true);
+                
original.setProperty(RedeliveryErrorHandler.CUTOFF_WARN_LOGGED, Boolean.TRUE);
+            }
         }
 
         if (processorExchangeFactory != null && pairs instanceof Collection) {
@@ -1238,8 +1266,13 @@ public class MulticastProcessor extends 
BaseProcessorSupport
                 // and wrap in unit of work processor so the copy exchange 
also can run under UoW
                 answer = createUnitOfWorkProcessor(route, processor, exchange);
 
-                // must start the error handler
-                ServiceHelper.startService(answer);
+                // Start before publishing to the cache: a second exchange to 
the same new recipient
+                // can take the cache-hit path and use the wrapper before it 
is started.
+                // isRunAllowed() covers STARTING/STARTED/SUSPENDING/SUSPENDED 
phases; during doInit()
+                // (INITIALIZING) it is false, so wrappers built there are 
deferred to doStart().
+                if (isRunAllowed()) {
+                    ServiceHelper.startService(answer);
+                }
 
                 if (errorHandlers != null) {
                     errorHandlers.putIfAbsent(cacheKey, answer);
@@ -1323,6 +1356,11 @@ public class MulticastProcessor extends 
BaseProcessorSupport
         }
         CamelContextAware.trySetCamelContext(aggregationStrategy, 
camelContext);
         ServiceHelper.startService(aggregationStrategy, processors, 
processorExchangeFactory);
+        // start any error handlers that were pre-built during doInit() but 
not yet started
+        // (they were deferred to avoid starting producers before CamelContext 
is fully started)
+        if (errorHandlers != null) {
+            ServiceHelper.startService(errorHandlers.values());
+        }
     }
 
     /**
@@ -1344,12 +1382,16 @@ public class MulticastProcessor extends 
BaseProcessorSupport
 
     @Override
     protected void doStop() throws Exception {
-        ServiceHelper.stopService(processors, errorHandlers, 
aggregationStrategy, processorExchangeFactory);
+        ServiceHelper.stopService(
+                processors, errorHandlers != null ? errorHandlers.values() : 
null,
+                aggregationStrategy, processorExchangeFactory);
     }
 
     @Override
     protected void doShutdown() throws Exception {
-        ServiceHelper.stopAndShutdownServices(processors, errorHandlers, 
aggregationStrategy, processorExchangeFactory);
+        ServiceHelper.stopAndShutdownServices(
+                processors, errorHandlers != null ? errorHandlers.values() : 
null,
+                aggregationStrategy, processorExchangeFactory);
         // only clear error handlers when shutting down
         if (errorHandlers != null) {
             errorHandlers.clear();
diff --git 
a/core/camel-core-processor/src/main/java/org/apache/camel/processor/errorhandler/RedeliveryErrorHandler.java
 
b/core/camel-core-processor/src/main/java/org/apache/camel/processor/errorhandler/RedeliveryErrorHandler.java
index 5c05406e6a86..10fbcc955210 100644
--- 
a/core/camel-core-processor/src/main/java/org/apache/camel/processor/errorhandler/RedeliveryErrorHandler.java
+++ 
b/core/camel-core-processor/src/main/java/org/apache/camel/processor/errorhandler/RedeliveryErrorHandler.java
@@ -80,6 +80,17 @@ public abstract class RedeliveryErrorHandler extends 
ErrorHandlerSupport
 
     private static final Logger LOG = 
LoggerFactory.getLogger(RedeliveryErrorHandler.class);
 
+    /**
+     * Exchange property key (internal): set to {@code Boolean.TRUE} by {@link 
SimpleTask#runNotAllowed()} immediately
+     * after it emits the cut-off WARN. Propagated across sub-exchange 
boundaries by
+     * {@link org.apache.camel.processor.MulticastProcessor} so that outer 
error handlers know a WARN was already logged
+     * for this cut-off and must not log a second one.
+     */
+    // Internal on purpose: a plain property, not an ExchangePropertyKey or a 
constant on Exchange, so it is not public
+    // API. As any plain property, it shows in Exchange.getProperties() and in 
tracer or debugger listings, and
+    // removeProperties("*") removes it.
+    public static final String CUTOFF_WARN_LOGGED = 
"CamelErrorHandlerCutOffLogged";
+
     // the exception of a failure that was continued (onException continued), 
which is not a previous exception of a
     // later failure. A key of its own: Exchange.EXCEPTION_HANDLED is the 
doCatch's marker that a previous doCatch of
     // the doTry handled the exception, and a direct call shares the exchange 
with the caller's doTry.
@@ -990,6 +1001,18 @@ public abstract class RedeliveryErrorHandler extends 
ErrorHandlerSupport
             LOG.trace("Run not allowed, will reject executing exchange: {}", 
exchange);
             if (exchange.getException() == null) {
                 exchange.setException(new 
RouteStoppingException(notAllowedReason()));
+            } else if (logger != null && 
!Boolean.TRUE.equals(exchange.getProperty(CUTOFF_WARN_LOGGED))) {
+                // log the cut-off WARN when the exchange already has an 
exception and a WARN has not yet
+                // been logged for this cut-off: logCutOffByRouteStop guards 
for instanceof RouteStoppingException
+                // and is a no-op for other exceptions. The exchange is 
passing through a stopped error handler
+                // (e.g. in MulticastProcessor.doStop()) while inflight. Mark 
CUTOFF_WARN_LOGGED after logging
+                // so that nested error handlers (e.g. a split inside a 
filter) do not each log their own WARN
+                // for the same exchange. Note: isInterrupted() is NOT used as 
the gate here — it may already
+                // be true from DirectProducer (CAMEL-25502) even when no WARN 
has been emitted yet.
+                if (logCutOffByRouteStop(exchange, exchange.getException(), 
logger.getLevel())) {
+                    exchange.getExchangeExtension().setInterrupted(true);
+                    exchange.setProperty(CUTOFF_WARN_LOGGED, Boolean.TRUE);
+                }
             }
             AsyncCallback cb = callback;
             taskFactory.release(this);
diff --git 
a/core/camel-core/src/test/java/org/apache/camel/impl/engine/DefaultSupervisingRouteControllerMulticastSplitTest.java
 
b/core/camel-core/src/test/java/org/apache/camel/impl/engine/DefaultSupervisingRouteControllerMulticastSplitTest.java
new file mode 100644
index 000000000000..d080aab40d70
--- /dev/null
+++ 
b/core/camel-core/src/test/java/org/apache/camel/impl/engine/DefaultSupervisingRouteControllerMulticastSplitTest.java
@@ -0,0 +1,195 @@
+/*
+ * 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.time.Duration;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.Component;
+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.builder.RouteBuilder;
+import org.apache.camel.spi.SupervisingRouteController;
+import org.apache.camel.support.DefaultComponent;
+import org.apache.camel.support.DefaultConsumer;
+import org.apache.camel.support.DefaultEndpoint;
+import org.apache.camel.support.DefaultProducer;
+import org.junit.jupiter.api.Test;
+
+import static org.awaitility.Awaitility.await;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * Tests that Split and Multicast EIPs do not start child producers during 
doInit(), which would bypass the
+ * SupervisingRouteController retry logic and fail the entire context when a 
producer cannot connect yet.
+ * <p>
+ * CAMEL-25823: Split and Multicast start their child producers before 
CamelContext starts, so with the supervising
+ * route controller a producer that cannot start yet fails the whole context.
+ */
+class DefaultSupervisingRouteControllerMulticastSplitTest extends 
ContextTestSupport {
+
+    @Override
+    public boolean isUseRouteBuilder() {
+        return false;
+    }
+
+    @Override
+    protected CamelContext createCamelContext() throws Exception {
+        CamelContext context = super.createCamelContext();
+        SupervisingRouteController src = 
context.getRouteController().supervising();
+        src.setBackOffDelay(25);
+        src.setBackOffMaxAttempts(10);
+        src.setInitialDelay(10);
+        src.setThreadPoolSize(2);
+        return context;
+    }
+
+    @Test
+    void testSplitWithFlakyProducer() throws Exception {
+        FlakyComponent component = new FlakyComponent(2);
+        context.addComponent("flaky", component);
+        context.addRoutes(new RouteBuilder() {
+            @Override
+            public void configure() {
+                from("direct:split")
+                        .routeId("split-route")
+                        .split(body())
+                        .to("flaky:target");
+            }
+        });
+
+        // Must not throw - supervising route controller should retry
+        context.start();
+
+        // Route should eventually reach Started state after retries
+        await().atMost(Duration.ofSeconds(5))
+                .untilAsserted(() -> assertEquals(ServiceStatus.Started,
+                        
context.getRouteController().getRouteStatus("split-route")));
+
+        // Verify the producer was retried (failCount=2 means 3 start attempts 
total)
+        FlakyEndpoint endpoint = (FlakyEndpoint) 
context.getEndpoint("flaky:target");
+        assertEquals(3, endpoint.startAttempts.get(), "Expected 3 start 
attempts (2 failures + 1 success)");
+
+        // Verify that a message can actually be processed once the route is 
started
+        template.sendBody("direct:split", "a,b,c");
+        assertEquals(3, endpoint.processCount.get(), "Expected 3 process calls 
for 3 split tokens");
+    }
+
+    @Test
+    void testMulticastWithFlakyProducer() throws Exception {
+        FlakyComponent component = new FlakyComponent(2);
+        context.addComponent("flaky", component);
+        context.addRoutes(new RouteBuilder() {
+            @Override
+            public void configure() {
+                from("direct:multicast")
+                        .routeId("multicast-route")
+                        .multicast()
+                        .to("flaky:target");
+            }
+        });
+
+        // Must not throw - supervising route controller should retry
+        context.start();
+
+        // Route should eventually reach Started state after retries
+        await().atMost(Duration.ofSeconds(5))
+                .untilAsserted(() -> assertEquals(ServiceStatus.Started,
+                        
context.getRouteController().getRouteStatus("multicast-route")));
+
+        // Verify the producer was retried (failCount=2 means 3 start attempts 
total)
+        FlakyEndpoint endpoint = (FlakyEndpoint) 
context.getEndpoint("flaky:target");
+        assertEquals(3, endpoint.startAttempts.get(), "Expected 3 start 
attempts (2 failures + 1 success)");
+
+        // Verify that a message can actually be processed once the route is 
started
+        template.sendBody("direct:multicast", "hello");
+        assertEquals(1, endpoint.processCount.get(), "Expected 1 process call 
for multicast to single target");
+    }
+
+    /**
+     * A component whose producer fails its first {@code failCount} doStart() 
calls with an exception, simulating a
+     * broker that is temporarily unavailable. The failure count is tracked on 
the endpoint (shared across producer
+     * instances) so retries eventually succeed even when a new producer is 
created on each restart attempt.
+     */
+    static class FlakyComponent extends DefaultComponent {
+
+        private final int failCount;
+
+        FlakyComponent(int failCount) {
+            this.failCount = failCount;
+        }
+
+        @Override
+        protected Endpoint createEndpoint(String uri, String remaining, 
Map<String, Object> parameters) {
+            return new FlakyEndpoint(uri, this, failCount);
+        }
+    }
+
+    static class FlakyEndpoint extends DefaultEndpoint {
+
+        final AtomicInteger startAttempts = new AtomicInteger();
+        final AtomicInteger processCount = new AtomicInteger();
+        private final int failCount;
+
+        FlakyEndpoint(String uri, Component component, int failCount) {
+            super(uri, component);
+            this.failCount = failCount;
+        }
+
+        @Override
+        public Producer createProducer() {
+            return new FlakyProducer(this);
+        }
+
+        @Override
+        public Consumer createConsumer(Processor processor) {
+            return new DefaultConsumer(this, processor);
+        }
+
+        boolean shouldFail() {
+            return startAttempts.incrementAndGet() <= failCount;
+        }
+    }
+
+    static class FlakyProducer extends DefaultProducer {
+
+        FlakyProducer(FlakyEndpoint endpoint) {
+            super(endpoint);
+        }
+
+        @Override
+        protected void doStart() throws Exception {
+            FlakyEndpoint ep = (FlakyEndpoint) getEndpoint();
+            if (ep.shouldFail()) {
+                throw new IllegalStateException("Cannot connect");
+            }
+        }
+
+        @Override
+        public void process(Exchange exchange) {
+            FlakyEndpoint ep = (FlakyEndpoint) getEndpoint();
+            ep.processCount.incrementAndGet();
+        }
+    }
+}
diff --git 
a/core/camel-core/src/test/java/org/apache/camel/processor/errorhandler/RouteStopCutOffCallerRouteTest.java
 
b/core/camel-core/src/test/java/org/apache/camel/processor/errorhandler/RouteStopCutOffCallerRouteTest.java
new file mode 100644
index 000000000000..1e9dfe3287bf
--- /dev/null
+++ 
b/core/camel-core/src/test/java/org/apache/camel/processor/errorhandler/RouteStopCutOffCallerRouteTest.java
@@ -0,0 +1,169 @@
+/*
+ * 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.processor.errorhandler;
+
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+import org.apache.camel.CamelExecutionException;
+import org.apache.camel.ContextTestSupport;
+import org.apache.camel.Exchange;
+import org.apache.camel.Processor;
+import org.apache.camel.RouteStoppingException;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+/**
+ * CAMEL-25823: when route B is force-stopped while an inflight exchange is 
inside a nested EIP (split inside
+ * multicast), the {@code .process()} step's own stopped channel handler 
re-enters {@code run()}, which sets a
+ * {@code RouteStoppingException} on the exchange (exchange had no exception 
yet). The next outer stopped handler finds
+ * the exception already set, logs the cut-off WARN, and marks the exchange as 
interrupted
+ * ({@code exchange.getExchangeExtension().setInterrupted(true)}). A calling 
route A that is still running and passed
+ * the same exchange into route B over {@code direct} then also skips its 
{@code onException}, redelivery, and dead
+ * letter channel — the same behaviour as when {@code DirectProducer} is cut 
off while waiting for a consumer
+ * (CAMEL-25502). This test pins down that behaviour: the DLC mock is NOT 
called when the nested-EIP cut-off propagates
+ * back to the caller.
+ *
+ * <p>
+ * Design: a {@code .process()} inside route B's nested EIP counts down an 
{@code entered} latch and then waits on a
+ * {@code release} latch (with a timeout). The test waits for {@code entered}, 
force-stops route B (setting its
+ * error-handler wrappers to stopped state), then releases the latch. The 
processor exits normally; the
+ * {@code .process()} step's own stopped channel handler re-enters {@code 
run()}, which sets a
+ * {@code RouteStoppingException}. The next outer stopped handler finds the 
exception already set, logs the WARN, and
+ * marks {@code interrupted=true}. Route A is kept running throughout; it has 
a DLC so it runs a {@code RedeliveryTask}
+ * whose {@code isDone()} path sees the interrupted flag and skips redelivery 
and the dead letter channel.
+ */
+public class RouteStopCutOffCallerRouteTest extends ContextTestSupport {
+
+    private final CountDownLatch entered = new CountDownLatch(1);
+    private final CountDownLatch release = new CountDownLatch(1);
+
+    /**
+     * Route A has a dead letter channel and sends to route B over {@code 
direct}. Route B is stopped inside a split
+     * nested in a multicast while a {@code .process()} is blocked on a latch. 
After the stop, the latch is released:
+     * the processor exits normally and the {@code .process()} step's own 
stopped channel handler re-enters
+     * {@code run()}, which sets a {@code RouteStoppingException}. The next 
outer stopped handler finds the exception
+     * already set, logs the WARN, and marks {@code interrupted=true}. Route A 
is kept running; it has a DLC so it runs
+     * a {@code RedeliveryTask} whose {@code isDone()} path sees the 
interrupted flag and skips redelivery,
+     * {@code onException}, and the dead letter channel.
+     */
+    @Test
+    public void callerRouteDlcNotCalledOnNestedEipCutOff() throws Exception {
+        MockEndpoint dlc = getMockEndpoint("mock:dlc");
+        dlc.expectedMessageCount(0);
+
+        MockEndpoint afterBlock = getMockEndpoint("mock:afterBlock");
+        afterBlock.expectedMessageCount(0);
+
+        // Route A sends synchronously to route B via direct (same exchange 
object).
+        // The Future lets us wait for the exchange to fully propagate back.
+        // Set a short shutdown timeout so stopRoute("nested-b") force-stops 
after 1 s instead of
+        // the default 300 s. route B is a direct: consumer with no executor 
of its own; the inflight
+        // thread belongs to seda:caller (route A) and is not interrupted by 
the force-stop.
+        context.getShutdownStrategy().setTimeout(1);
+        context.getShutdownStrategy().setTimeUnit(TimeUnit.SECONDS);
+
+        Future<Object> future = template.asyncRequestBody("seda:caller", "x", 
Object.class);
+
+        // Wait until the processor inside route B's nested EIP has entered 
and is blocking.
+        assertTrue(entered.await(5, TimeUnit.SECONDS), "Processor did not 
enter within 5 seconds");
+
+        // 1. Stop route B: waits up to 1 s for the inflight exchange, then 
force-stops.
+        //    Force-stop calls MulticastProcessor.doStop(), which stops the 
nested error-handler wrappers
+        //    (isStoppingOrStopped() returns true). The seda:caller thread is 
NOT interrupted.
+        //    Route A is NOT stopped — it stays running throughout the test.
+        context.getRouteController().stopRoute("nested-b");
+
+        // 2. Release the latch: the processor exits normally (no interrupt, 
no manually thrown exception).
+        //    The .process() step's own stopped channel handler re-enters 
run(), which sets a
+        //    RouteStoppingException. The next outer stopped handler finds the 
exception already set, logs
+        //    the WARN, and marks interrupted=true. Route A has a DLC so it 
runs a RedeliveryTask
+        //    whose isDone() path sees the interrupted flag and skips 
redelivery and the dead letter channel.
+        release.countDown();
+
+        // Wait for the exchange to finish. It should complete with a 
RouteStoppingException
+        // (route A has a DLC so it takes the RedeliveryTask.isDone() path, 
which keeps the exception
+        // and fires the callback without invoking the DLC).
+        ExecutionException ex = null;
+        try {
+            future.get(10, TimeUnit.SECONDS);
+        } catch (ExecutionException e) {
+            ex = e;
+        } catch (TimeoutException e) {
+            fail("Exchange did not complete within 10 seconds — likely a hang 
in the error handler chain", e);
+        }
+        assertNotNull(ex, "Expected the exchange to fail with a 
RouteStoppingException");
+        assertInstanceOf(CamelExecutionException.class, ex.getCause());
+        assertInstanceOf(RouteStoppingException.class, 
ex.getCause().getCause());
+
+        // mock:afterBlock must NOT have been called: the stopped 
error-handler wrapper intercepted
+        // the exchange before it reached the endpoint.
+        afterBlock.assertIsSatisfied();
+
+        // The DLC must NOT have been called: route A's error handler skipped 
it because the exchange
+        // was marked as interrupted by route B's nested stopped error handler.
+        dlc.assertIsSatisfied();
+    }
+
+    @Override
+    protected RouteBuilder createRouteBuilder() {
+        return new RouteBuilder() {
+            @Override
+            public void configure() {
+                // Route A: dead letter channel configured; calls route B via 
direct (same exchange object).
+                // Route A is NOT stopped during the test — it stays running 
throughout.
+                from("seda:caller").routeId("caller-a")
+                        
.errorHandler(deadLetterChannel("mock:dlc").maximumRedeliveries(0))
+                        .to("direct:b");
+
+                // Route B: split nested inside multicast. The processor 
counts down the entered latch
+                // and then waits on the release latch (with a timeout so the 
test cannot hang).
+                // After the processor exits, the .process() step's own 
stopped channel handler
+                // re-enters run(), which sets a RouteStoppingException. The 
next outer stopped handler
+                // finds the exception already set, logs the WARN, and marks 
interrupted=true.
+                // MulticastProcessor propagates interrupted=true to the 
original exchange,
+                // so route A's running error handler 
(RedeliveryTask.isDone()) skips the DLC.
+                from("direct:b").routeId("nested-b")
+                        .multicast()
+                        .split(body())
+                        .process(new Processor() {
+                            @Override
+                            public void process(Exchange exchange) throws 
Exception {
+                                entered.countDown();
+                                // Block until the test releases, or until 30 
s to avoid
+                                // hanging the test suite if something goes 
wrong.
+                                if (!release.await(30, TimeUnit.SECONDS)) {
+                                    fail("Release latch timed out after 30 
seconds");
+                                }
+                            }
+                        })
+                        .to("mock:afterBlock").id("afterBlock")
+                        .end()
+                        .end();
+            }
+        };
+    }
+}
diff --git 
a/core/camel-core/src/test/java/org/apache/camel/processor/errorhandler/RouteStopCutOffLogNestedTest.java
 
b/core/camel-core/src/test/java/org/apache/camel/processor/errorhandler/RouteStopCutOffLogNestedTest.java
new file mode 100644
index 000000000000..07782de8bbde
--- /dev/null
+++ 
b/core/camel-core/src/test/java/org/apache/camel/processor/errorhandler/RouteStopCutOffLogNestedTest.java
@@ -0,0 +1,253 @@
+/*
+ * 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.processor.errorhandler;
+
+import java.util.Queue;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+import org.apache.camel.ContextTestSupport;
+import org.apache.camel.Exchange;
+import org.apache.camel.Processor;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.log.ConsumingAppender;
+import org.apache.logging.log4j.Level;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.core.LoggerContext;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+/**
+ * CAMEL-25823: verifies that a route-stop cut-off produces exactly one WARN 
line even when the exchange passes through
+ * multiple nested stopped error handlers. Two scenarios are tested:
+ *
+ * <ol>
+ * <li>{@code filter} — a filter wrapping a {@code .process()} step gives the 
exchange two stopped error-handler
+ * channels in route B (the {@code .process()} step's channel and the filter's 
channel) plus route A's running channel.
+ * The {@code setInterrupted(true)} marker set by the filter's stopped channel 
prevents route A's running handler from
+ * logging the same cut-off again.</li>
+ * <li>{@code multicast+split} — a {@code split} nested inside a {@code 
multicast} creates a sub-exchange copy.
+ * {@code MulticastProcessor} propagates the {@code interrupted} flag from the 
sub-exchange to the original so that
+ * neither the original exchange's channel nor the caller's error handler logs 
a second WARN.</li>
+ * </ol>
+ *
+ * <p>
+ * Design (both tests): route A ({@code seda:start} or {@code seda:start-ms}) 
forwards to route B via {@code direct}
+ * (same exchange object, no thread switch). A {@code .process()} inside B's 
nested EIP counts down an {@code entered}
+ * latch and waits on a {@code release} latch. The test waits for {@code 
entered}, stops route B (whose error-handler
+ * wrappers are now in stopped state), then releases the latch. The processor 
exits normally; the {@code .process()}
+ * step's own stopped channel handler re-enters {@code run()}, which sets the 
{@code RouteStoppingException} on the
+ * (sub-)exchange. The next stopped handler logs the WARN and marks {@code 
interrupted=true}. Subsequent stopped
+ * handlers and the caller's running handler see the flag and skip. The WARN 
count is asserted after the exchange
+ * completes.
+ */
+public class RouteStopCutOffLogNestedTest extends ContextTestSupport {
+
+    private static final String LOGGER = DefaultErrorHandler.class.getName();
+
+    private final Queue<String> messages = new ConcurrentLinkedQueue<>();
+    private final CountDownLatch entered = new CountDownLatch(1);
+    private final CountDownLatch release = new CountDownLatch(1);
+
+    @AfterEach
+    public void removeAppender() {
+        LoggerContext ctx = (LoggerContext) LogManager.getContext(false);
+        ctx.getConfiguration().removeLogger(LOGGER);
+        ctx.updateLoggers();
+    }
+
+    /**
+     * A filter wrapping the blocked processor gives the exchange two stopped 
error-handler channels in route B (the
+     * {@code .process()} step's channel and the filter's channel). The 
single-WARN guarantee should hold: only one WARN
+     * line per exchange, not one per stopped channel or per route.
+     */
+    @Test
+    public void nestedEipCutOffIsOneWarnLine() throws Exception {
+        // The tests in this module run in parallel, and log through the same 
error handler logger:
+        // only keep the events from the threads of this CamelContext.
+        String threadMarker = "(" + context.getName() + ")";
+        ConsumingAppender.newAppender(LOGGER, "RouteStopCutOffLogNestedTest", 
Level.DEBUG, event -> {
+            if (event.getThreadName() != null && 
event.getThreadName().contains(threadMarker)) {
+                messages.add(event.getLevel() + " " + 
event.getMessage().getFormattedMessage()
+                             + (event.getThrown() != null ? " THROWN" : ""));
+            }
+        });
+        // A short shutdown timeout ensures stopRoute force-stops route B 
after 1 s.
+        // Route B is a direct: consumer with no executor thread of its own; 
the seda:start
+        // consumer thread is NOT interrupted when only route B is stopped.
+        context.getShutdownStrategy().setTimeout(1);
+        context.getShutdownStrategy().setTimeUnit(TimeUnit.SECONDS);
+
+        // Send asynchronously so the test thread is free to call stopRoute.
+        Future<Object> future = template.asyncRequestBody("seda:start", "x", 
Object.class);
+
+        // Wait until the processor inside route B's nested EIP has entered 
and is blocking.
+        assertTrue(entered.await(5, TimeUnit.SECONDS), "Processor did not 
enter within 5 seconds");
+
+        // Stop route B: stopRouteService stops B's error-handler wrappers 
after the 1 s force-stop.
+        // The seda:start consumer thread is NOT interrupted (it owns no 
thread in route B).
+        context.getRouteController().stopRoute("nested");
+
+        // Release the latch: the processor exits normally (no interrupt, no 
manually thrown exception).
+        // The .process() step's own stopped channel handler re-enters run(), 
which sets a
+        // RouteStoppingException. The filter's stopped channel handler finds 
the exception already set,
+        // logs the WARN, and marks interrupted=true. Route A's running 
channel sees the flag and skips.
+        release.countDown();
+
+        // Wait for the exchange to complete fully.
+        try {
+            future.get(10, TimeUnit.SECONDS);
+        } catch (ExecutionException e) {
+            // expected: RouteStoppingException propagated back
+        } catch (TimeoutException e) {
+            fail("Exchange did not complete within 10 seconds — likely a hang 
in the error handler chain", e);
+        }
+
+        // Exactly one WARN should be emitted, even though the exchange passed 
through two nested
+        // stopped error-handler channels in route B (process step, filter) 
plus route A's running channel.
+        assertEquals(1, messages.size(), messages.toString());
+        String message = messages.peek();
+        assertTrue(message.startsWith("WARN Exchange cut off (MessageId: "), 
message);
+        assertTrue(message.contains(": its route is being stopped or reloaded, 
so it is not continued."), message);
+        assertFalse(message.contains("Exhausted"), message);
+        assertFalse(message.contains("Message History"), message);
+        assertFalse(message.endsWith("THROWN"), message);
+    }
+
+    /**
+     * A {@code split} nested inside a {@code multicast} creates a 
sub-exchange copy for each split item.
+     * {@code MulticastProcessor} propagates the {@code interrupted} flag from 
the sub-exchange to the original
+     * exchange, so the original exchange's channel and route A's running 
error handler also skip logging. The
+     * single-WARN guarantee holds across the sub-exchange copy boundary.
+     */
+    @Test
+    public void multicastSplitCutOffIsOneWarnLine() throws Exception {
+        String threadMarker = "(" + context.getName() + ")";
+        ConsumingAppender.newAppender(LOGGER, "RouteStopCutOffLogNestedTest", 
Level.DEBUG, event -> {
+            if (event.getThreadName() != null && 
event.getThreadName().contains(threadMarker)) {
+                messages.add(event.getLevel() + " " + 
event.getMessage().getFormattedMessage()
+                             + (event.getThrown() != null ? " THROWN" : ""));
+            }
+        });
+        context.getShutdownStrategy().setTimeout(1);
+        context.getShutdownStrategy().setTimeUnit(TimeUnit.SECONDS);
+
+        // Send a single-item body so the split produces exactly one 
sub-exchange.
+        Future<Object> future = template.asyncRequestBody("seda:start-ms", 
"x", Object.class);
+
+        assertTrue(entered.await(5, TimeUnit.SECONDS), "Processor did not 
enter within 5 seconds");
+
+        // Stop route B: MulticastProcessor.doStop() stops the nested 
error-handler wrappers.
+        // The seda:start-ms consumer thread is NOT interrupted.
+        context.getRouteController().stopRoute("nested-ms");
+
+        // Release the latch: the processor exits normally. The .process() 
step's stopped channel
+        // handler sets the RouteStoppingException on the sub-exchange. The 
split's stopped channel
+        // handler logs the WARN and marks the sub-exchange interrupted=true. 
MulticastProcessor
+        // propagates interrupted=true to the original exchange. The 
multicast's channel and route A's
+        // running channel see the flag and skip logging.
+        release.countDown();
+
+        try {
+            future.get(10, TimeUnit.SECONDS);
+        } catch (ExecutionException e) {
+            // expected: RouteStoppingException propagated back
+        } catch (TimeoutException e) {
+            fail("Exchange did not complete within 10 seconds — likely a hang 
in the error handler chain", e);
+        }
+
+        // Exactly one WARN even though the cut-off crosses a sub-exchange 
copy boundary.
+        assertEquals(1, messages.size(), messages.toString());
+        String message = messages.peek();
+        assertTrue(message.startsWith("WARN Exchange cut off (MessageId: "), 
message);
+        assertTrue(message.contains(": its route is being stopped or reloaded, 
so it is not continued."), message);
+        assertFalse(message.contains("Exhausted"), message);
+        assertFalse(message.contains("Message History"), message);
+        assertFalse(message.endsWith("THROWN"), message);
+    }
+
+    @Override
+    protected RouteBuilder createRouteBuilder() {
+        return new RouteBuilder() {
+            @Override
+            public void configure() {
+                // Route A (filter test): forwards to route B via direct (same 
exchange object, no thread switch).
+                // Route A is NOT stopped during the test — it stays running 
throughout.
+                from("seda:start").routeId("caller-nested")
+                        .to("direct:nested");
+
+                // Route B (filter test): a filter wrapping the processor 
gives the exchange two stopped
+                // error-handler channels in route B. The processor counts 
down the entered latch and
+                // waits on the release latch. After the processor exits, the 
.process() step's stopped
+                // channel sets the RouteStoppingException; the filter's 
stopped channel logs the WARN
+                // and marks interrupted=true; route A's running channel sees 
the flag and skips.
+                from("direct:nested").routeId("nested")
+                        .filter(constant(true))
+                        .process(new Processor() {
+                            @Override
+                            public void process(Exchange exchange) throws 
Exception {
+                                entered.countDown();
+                                // Block until the test releases, or 30 s to 
avoid hanging
+                                // the test suite if something goes wrong.
+                                if (!release.await(30, TimeUnit.SECONDS)) {
+                                    fail("Release latch timed out after 30 
seconds");
+                                }
+                            }
+                        })
+                        .to("mock:afterBlock")
+                        .end()
+                        .to("mock:result");
+
+                // Route A (multicast+split test): forwards to route B via 
direct (same exchange object).
+                // Route A is NOT stopped — it stays running throughout.
+                from("seda:start-ms").routeId("caller-nested-ms")
+                        .to("direct:nested-ms");
+
+                // Route B (multicast+split test): a split nested inside a 
multicast.
+                // The split creates a sub-exchange copy. After the processor 
exits, the .process() step's
+                // stopped channel sets the RouteStoppingException on the 
sub-exchange; the split's stopped
+                // channel logs the WARN and marks the sub-exchange 
interrupted=true.
+                // MulticastProcessor propagates interrupted=true to the 
original exchange,
+                // so the multicast's channel and route A's running channel 
both skip logging.
+                from("direct:nested-ms").routeId("nested-ms")
+                        .multicast()
+                        .split(body())
+                        .process(new Processor() {
+                            @Override
+                            public void process(Exchange exchange) throws 
Exception {
+                                entered.countDown();
+                                if (!release.await(30, TimeUnit.SECONDS)) {
+                                    fail("Release latch timed out after 30 
seconds");
+                                }
+                            }
+                        })
+                        .to("mock:afterBlock-ms")
+                        .end()
+                        .end();
+            }
+        };
+    }
+}
diff --git 
a/core/camel-core/src/test/java/org/apache/camel/processor/errorhandler/RouteStopCutOffLogTest.java
 
b/core/camel-core/src/test/java/org/apache/camel/processor/errorhandler/RouteStopCutOffLogTest.java
index 4d33c34bee03..d8075554f11f 100644
--- 
a/core/camel-core/src/test/java/org/apache/camel/processor/errorhandler/RouteStopCutOffLogTest.java
+++ 
b/core/camel-core/src/test/java/org/apache/camel/processor/errorhandler/RouteStopCutOffLogTest.java
@@ -18,10 +18,14 @@ package org.apache.camel.processor.errorhandler;
 
 import java.util.List;
 import java.util.Queue;
+import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
 
 import org.apache.camel.ContextTestSupport;
+import org.apache.camel.Exchange;
 import org.apache.camel.RouteStoppingException;
 import org.apache.camel.builder.RouteBuilder;
 import org.apache.camel.component.log.ConsumingAppender;
@@ -34,7 +38,10 @@ 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.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
 
 /**
  * CAMEL-25484: an exchange that a route stop or a dev mode reload cuts off is 
logged as one WARN line, not as an
@@ -45,6 +52,8 @@ public class RouteStopCutOffLogTest extends 
ContextTestSupport {
     private static final String LOGGER = DefaultErrorHandler.class.getName();
 
     private final Queue<String> messages = new ConcurrentLinkedQueue<>();
+    private final CountDownLatch entered = new CountDownLatch(1);
+    private final CountDownLatch release = new CountDownLatch(1);
 
     @AfterEach
     public void removeAppender() {
@@ -53,6 +62,10 @@ public class RouteStopCutOffLogTest extends 
ContextTestSupport {
         ctx.updateLoggers();
     }
 
+    // ---- Original DirectProducer cut-off tests (CAMEL-25484 / CAMEL-25502) 
----
+    // These use seda:start → split → direct:shipment (no consumer). The seda 
thread is interrupted
+    // by stopRoute, which causes DirectProducer to set the 
RouteStoppingException and log one WARN.
+
     @Test
     public void theCutOffIsOneWarnLine() throws Exception {
         // the tests in this module run in parallel, and log through the same 
error handler logger:
@@ -66,7 +79,6 @@ public class RouteStopCutOffLogTest extends 
ContextTestSupport {
         });
         context.getShutdownStrategy().setTimeout(1);
         context.getShutdownStrategy().setTimeUnit(TimeUnit.SECONDS);
-
         context.getInflightRepository().setInflightBrowseEnabled(true);
 
         template.sendBody("seda:start", List.of("A", "B"));
@@ -121,17 +133,119 @@ public class RouteStopCutOffLogTest extends 
ContextTestSupport {
                 () -> context.getErrorRegistry().browse().toString());
     }
 
+    // ---- Latch-based cut-off tests (this PR) ----
+    // These use direct:start → split with a processor that blocks. stopRoute 
force-stops after the
+    // timeout, and the stopped error-handler chain emits exactly one cut-off 
WARN.
+
+    @Test
+    public void processedCutOffIsOneWarnLine() throws Exception {
+        String threadMarker = "(" + context.getName() + ")";
+        ConsumingAppender.newAppender(LOGGER, "RouteStopCutOffLogTest", 
Level.DEBUG, event -> {
+            if (event.getThreadName() != null && 
event.getThreadName().contains(threadMarker)) {
+                messages.add(event.getLevel() + " " + 
event.getMessage().getFormattedMessage()
+                             + (event.getThrown() != null ? " THROWN" : ""));
+            }
+        });
+        // Use a bounded shutdown timeout so that stopRoute force-stops the 
route (stopping its
+        // error handlers) after the timeout, since the inflight exchange will 
not complete on its own.
+        // Using direct: (not seda:) ensures the route stop does not interrupt 
the processing thread.
+        context.getShutdownStrategy().setTimeout(5);
+        context.getShutdownStrategy().setTimeUnit(TimeUnit.SECONDS);
+
+        CompletableFuture<Exchange> future = 
template.asyncSend("direct:start", exchange -> {
+            exchange.getIn().setBody(List.of("A"));
+        });
+
+        assertTrue(entered.await(5, TimeUnit.SECONDS), "Processor did not 
enter within 5 seconds");
+        context.getRouteController().stopRoute("slow-latch");
+        release.countDown();
+
+        Exchange result = null;
+        try {
+            result = future.get(10, TimeUnit.SECONDS);
+        } catch (TimeoutException e) {
+            fail("Exchange did not complete within 10 seconds — likely a hang 
in the error handler chain", e);
+        }
+        assertNotNull(result);
+        assertInstanceOf(RouteStoppingException.class, result.getException());
+
+        assertEquals(1, messages.size(), messages.toString());
+        String message = messages.peek();
+        assertTrue(message.startsWith("WARN Exchange cut off (MessageId: "), 
message);
+        assertTrue(message.contains(": its route is being stopped or reloaded, 
so it is not continued."), message);
+        assertFalse(message.contains("Exhausted"), message);
+        assertFalse(message.contains("Message History"), message);
+        assertFalse(message.endsWith("THROWN"), message);
+    }
+
+    @Test
+    public void processedCutOffIsNotInTheErrorRegistry() throws Exception {
+        context.getErrorRegistry().setEnabled(true);
+        context.getShutdownStrategy().setTimeout(5);
+        context.getShutdownStrategy().setTimeUnit(TimeUnit.SECONDS);
+
+        template.asyncSend("direct:start", exchange -> 
exchange.getIn().setBody(List.of("A")));
+
+        assertTrue(entered.await(5, TimeUnit.SECONDS), "Processor did not 
enter within 5 seconds");
+        context.getRouteController().stopRoute("slow-latch");
+        release.countDown();
+
+        await().atMost(10, TimeUnit.SECONDS)
+                .until(() -> 
context.getRouteController().getRouteStatus("slow-latch").isStopped());
+        assertEquals(0, context.getErrorRegistry().size(), () -> 
context.getErrorRegistry().browse().toString());
+    }
+
+    @Test
+    public void processedCutOffIsInTheErrorRegistryWhenIncluded() throws 
Exception {
+        context.getErrorRegistry().setEnabled(true);
+        context.getErrorRegistry().setIncludeRouteStopping(true);
+        context.getShutdownStrategy().setTimeout(5);
+        context.getShutdownStrategy().setTimeUnit(TimeUnit.SECONDS);
+
+        template.asyncSend("direct:start", exchange -> 
exchange.getIn().setBody(List.of("A")));
+
+        assertTrue(entered.await(5, TimeUnit.SECONDS), "Processor did not 
enter within 5 seconds");
+        context.getRouteController().stopRoute("slow-latch");
+        release.countDown();
+
+        await().atMost(10, TimeUnit.SECONDS).until(() -> 
context.getErrorRegistry().size() > 0);
+        assertTrue(context.getErrorRegistry().browse().stream()
+                .allMatch(e -> 
RouteStoppingException.class.getName().equals(e.getExceptionType())),
+                () -> context.getErrorRegistry().browse().toString());
+    }
+
     @Override
     protected RouteBuilder createRouteBuilder() {
         return new RouteBuilder() {
             @Override
             public void configure() {
+                // Original scenario: DirectProducer cut-off (CAMEL-25484 / 
CAMEL-25502)
                 from("seda:start").routeId("slow")
                         .split(body())
                             .log("Picked ${body}")
                             .to("direct:shipment").id("shipment")
                         .end()
                         .to("mock:result");
+
+                // Latch-based scenario: runNotAllowed() cut-off through 
stopped error handler
+                from("direct:start").routeId("slow-latch")
+                        .split(body())
+                        .log("Picked ${body}")
+                        // Block here so the test can stop the route while an 
exchange is inflight
+                        // inside the split. When the route is stopped, the 
stopped error-handler
+                        // chain emits exactly one cut-off WARN.
+                        .process(exchange -> {
+                            entered.countDown();
+                            try {
+                                if (!release.await(30, TimeUnit.SECONDS)) {
+                                    throw new IllegalStateException("release 
latch timed out");
+                                }
+                            } catch (InterruptedException e) {
+                                Thread.currentThread().interrupt();
+                            }
+                        })
+                        .end()
+                        .to("mock:result2");
             }
         };
     }
diff --git 
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
index 0054539bf3db..84ba80b8ff78 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
@@ -1266,6 +1266,30 @@ The generated Endpoint DSL header accessors on the Swift 
`HeaderNameBuilder` are
 `openstackSwiftVersionsLocation()`, `xContainerRead()` -> 
`openstackSwiftContainerRead()`, `xContainerWrite()` ->
 `openstackSwiftContainerWrite()`).
 
+=== camel-core - Split and Multicast child producers start with their route
+
+`Split` and `Multicast` EIPs now start their child output producers during the 
route's own start, instead of during
+`CamelContext` initialization. The visible changes are:
+
+* The producers now start when the route starts, not during `CamelContext` 
initialization. A `CamelContext` that is
+  only built or initialized (without starting routes) no longer starts them.
+* Each route's producers now start in `startupOrder` with their route, instead 
of ahead of all routes.
+* The producers of an `autoStartup=false` route are no longer started when the 
context starts; they start when the
+  route itself is started (regardless of which route controller is used).
+
+=== camel-core - Forced route-stop cut-off marks exchange as interrupted in 
any nested EIP
+
+An exchange that a forced route stop cuts off inside a nested EIP (`split`, 
`multicast`,
+`filter`, `choice`, ...) of the stopped route is now marked as interrupted
+(`exchange.getExchangeExtension().setInterrupted(true)`) after the cut-off 
WARN is logged. This prevents
+nested stopped error handlers from each emitting their own WARN for the same 
exchange. The interrupted flag
+also causes the error handler to skip redelivery, `onException` routes, and 
the dead letter channel — the
+same behavior that `DirectProducer` applies (CAMEL-25502) when a route is 
stopped while an exchange is
+waiting for a consumer. A calling route that is still running (for example 
through `direct`) then also no
+longer runs its `onException`, redelivery or dead letter channel for that 
exchange. If your application
+relied on `onException` or the dead letter channel handling a 
`RouteStoppingException` from a nested EIP
+of the stopped route, it will no longer trigger on a forced route stop.
+
 === camel-opentelemetry-metrics - a counter increment or decrement that does 
not give a number fails the exchange
 
 A `counter` with the `increment` or `decrement` option (such as 
`+increment=${body}+` or `+increment=${header.amount}+`)

Reply via email to