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 9a171984d3c0 CAMEL-26464: camel-core - an AsyncProcessor that throws 
in a doTry or an onException fails the exchange instead of hanging (#29127)
9a171984d3c0 is described below

commit 9a171984d3c01a04c7e40f225e82b115e16e7dc8
Author: allthingssecurity <[email protected]>
AuthorDate: Wed Nov 4 14:11:32 2026 +0530

    CAMEL-26464: camel-core - an AsyncProcessor that throws in a doTry or an 
onException fails the exchange instead of hanging (#29127)
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../camel/impl/engine/CamelInternalProcessor.java  | 108 ++++-
 .../apache/camel/impl/engine/DefaultChannel.java   |   3 +
 ...thoutErrorHandlerProcessorThrowsPooledTest.java |  38 ++
 ...StepWithoutErrorHandlerProcessorThrowsTest.java | 226 ++++++++++
 ...houtErrorHandlerProcessorThrowsTestSupport.java | 453 +++++++++++++++++++++
 .../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc    |  15 +
 6 files changed, 837 insertions(+), 6 deletions(-)

diff --git 
a/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/CamelInternalProcessor.java
 
b/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/CamelInternalProcessor.java
index a43b8ce41b79..0fa6cd81b25a 100644
--- 
a/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/CamelInternalProcessor.java
+++ 
b/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/CamelInternalProcessor.java
@@ -23,6 +23,7 @@ import java.util.Objects;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.ConcurrentMap;
 import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.atomic.AtomicIntegerFieldUpdater;
 
 import org.apache.camel.AsyncCallback;
 import org.apache.camel.CamelContext;
@@ -118,6 +119,9 @@ public class CamelInternalProcessor extends 
DelegateAsyncProcessor implements In
 
     private static final Object[] EMPTY_STATES = new Object[0];
 
+    private static final AtomicIntegerFieldUpdater<AsyncAfterTask> AWAITED
+            = AtomicIntegerFieldUpdater.newUpdater(AsyncAfterTask.class, 
"awaited");
+
     final CamelContext camelContext;
     private final ReactiveExecutor reactiveExecutor;
     private final ShutdownStrategy shutdownStrategy;
@@ -127,6 +131,10 @@ public class CamelInternalProcessor extends 
DelegateAsyncProcessor implements In
     // the id of the step, when this is the channel of a step that asks the 
step hook itself, as it has no error handler
     // that does (set by DefaultChannel)
     String stepHookNodeId;
+    // whether the processor is the step itself, with no error handler around 
it (set by DefaultChannel, such as for the
+    // steps of a doTry or an onException): this then catches an exception 
that the processor throws instead of
+    // calling the callback, which would otherwise be dropped by the reactive 
executor and leave the exchange hanging
+    boolean catchProcessorThrows;
 
     public CamelInternalProcessor(CamelContext camelContext) {
         this.camelContext = camelContext;
@@ -241,6 +249,12 @@ public class CamelInternalProcessor extends 
DelegateAsyncProcessor implements In
         private final Object[] states;
         private Exchange exchange;
         private AsyncCallback originalCallback;
+        // only when catchProcessorThrows: n while the callback of call n of 
the processor with this task is still to
+        // come, and -n once call n is settled (0 before the first call). The 
callback (done) and the takeover of a
+        // processor that throws before it calls the callback compete for it 
(compareAndSet from n to -n), so the
+        // exchange is continued exactly once. The calls are numbered, as this 
task is reused when tasks are pooled. One
+        // int field, not an object per call, as this task runs every step 
(the field fits in the padding of the task)
+        volatile int awaited;
 
         private AsyncAfterTask(Object[] states) {
             this.states = states;
@@ -250,6 +264,16 @@ public class CamelInternalProcessor extends 
DelegateAsyncProcessor implements In
         public void prepare(Exchange exchange, AsyncCallback originalCallback) 
{
             this.exchange = exchange;
             this.originalCallback = originalCallback;
+            if (catchProcessorThrows) {
+                int call = Math.abs(awaited) + 1;
+                if (call <= 0) {
+                    // the numbers wrap around after Integer.MAX_VALUE calls
+                    call = 1;
+                }
+                // an ordered write is enough: the callback reaches another 
thread only through a hand-off that
+                // publishes it
+                AWAITED.lazySet(this, call);
+            }
         }
 
         @Override
@@ -266,6 +290,19 @@ public class CamelInternalProcessor extends 
DelegateAsyncProcessor implements In
 
         @Override
         public void done(boolean doneSync) {
+            if (catchProcessorThrows) {
+                final int call = awaited;
+                if (call <= 0 || !AWAITED.compareAndSet(this, call, -call)) {
+                    // the logs here do not read the exchange id: it is 
created on first use, and the exchange may be
+                    // in use on another thread
+                    // (stepHookNodeId is the id of the step in a channel 
without an error handler)
+                    LOG.warn("The callback of {} (step {}) was called when it 
was no longer awaited (after the processor"
+                             + " had thrown an exception, which failed the 
exchange instead, or more than once; an"
+                             + " AsyncProcessor must not throw from process). 
This callback is ignored.",
+                            processor, stepHookNodeId);
+                    return;
+                }
+            }
             try {
                 AdviceIterator.runAfterTasks(advices, states, exchange);
             } finally {
@@ -286,6 +323,44 @@ public class CamelInternalProcessor extends 
DelegateAsyncProcessor implements In
                 }
             }
         }
+
+        /**
+         * Continues the exchange with the exception that the processor threw 
before it called its callback, unless the
+         * callback came first.
+         */
+        void takeOver(int call, Throwable cause) {
+            if (!AWAITED.compareAndSet(this, call, -call)) {
+                // the callback continued the exchange (which may already be 
done, or in use on another thread, so its
+                // id is not read here), so this exception is only logged
+                LOG.warn("Error processing an exchange by {} (step {}) due to 
{}. The exchange continued, so this"
+                         + " exception is ignored.",
+                        processor, stepHookNodeId, cause.getMessage(), cause);
+                return;
+            }
+            // this task still holds the exchange and the callback of this 
call: only the callback, by winning the
+            // compareAndSet, could have released it. Fail the exchange, as 
when the processor sets the exception on
+            // the exchange and calls the callback
+            final AsyncCallback originalCallback = this.originalCallback;
+            exchange.setException(cause);
+            try {
+                AdviceIterator.runAfterTasks(advices, states, exchange);
+            } finally {
+                try {
+                    // the after processing of the unit of work that the 
callback of the processor would have done after
+                    // this task (such as clearing the MDC)
+                    final UnitOfWork uow = exchange.getUnitOfWork();
+                    if (uow != null && uow.isBeforeAfterProcess()) {
+                        uow.afterProcess(processor, exchange, this, false);
+                    }
+                } finally {
+                    reactiveExecutor.schedule(originalCallback);
+                }
+                // this task is not released: the processor may still call its 
callback (this task), which must find no
+                // call awaited and be ignored, instead of continuing an 
exchange that reuses this task. It keeps no
+                // reference to the exchange, which a broken processor may 
hold on to with its callback
+                reset();
+            }
+        }
     }
 
     @Override
@@ -322,7 +397,7 @@ public class CamelInternalProcessor extends 
DelegateAsyncProcessor implements In
         Object[] states;
 
         // create internal callback which will execute the advices in reverse 
order when done
-        CamelInternalTask afterTask = taskFactory != null ? 
taskFactory.acquire() : null;
+        AsyncAfterTask afterTask = taskFactory != null ? (AsyncAfterTask) 
taskFactory.acquire() : null;
         if (afterTask == null) {
             states = statefulAdvices > 0 ? new Object[statefulAdvices] : 
EMPTY_STATES;
             afterTask = new AsyncAfterTask(states);
@@ -381,7 +456,9 @@ public class CamelInternalProcessor extends 
DelegateAsyncProcessor implements In
         return true;
     }
 
-    private boolean processNonTransacted(Exchange exchange, CamelInternalTask 
afterTask) {
+    private boolean processNonTransacted(Exchange exchange, AsyncAfterTask 
afterTask) {
+        // read while this call owns the task: once the callback is called, 
the task may be released and reused
+        final int call = catchProcessorThrows ? afterTask.awaited : 0;
         final AsyncCallback async = beforeProcess(exchange, afterTask);
 
         // ----------------------------------------------------------
@@ -391,10 +468,29 @@ public class CamelInternalProcessor extends 
DelegateAsyncProcessor implements In
             LOG.trace("Processing exchange for exchangeId: {} -> {}", 
exchange.getExchangeId(), exchange);
         }
         final String nodeId = stepHookNodeId;
-        boolean sync = nodeId == null
-                ? processor.process(exchange, async)
-                : 
StepHelper.process(camelContext.getCamelContextExtension().getStepHook(), 
exchange, nodeId, processor,
-                        async);
+        boolean sync;
+        try {
+            sync = nodeId == null
+                    ? processor.process(exchange, async)
+                    : 
StepHelper.process(camelContext.getCamelContextExtension().getStepHook(), 
exchange, nodeId, processor,
+                            async);
+        } catch (Throwable e) {
+            // a Throwable, not only an Exception, as in the other fixes of 
this kind (SimpleTask, RedeliveryTask,
+            // NoErrorHandler): an Error such as NoClassDefFoundError thrown 
before the callback would also hang
+            if (!catchProcessorThrows || afterTask.awaited != call) {
+                // an error handler around the processor handles it, or the 
callback was called before the exception was
+                // thrown and continued the exchange (this task may already be 
released): leave the exception to the
+                // caller, as before
+                throw e;
+            }
+            // the processor threw before it called the callback, such as an 
AsyncProcessor that throws from
+            // process(exchange, callback) instead of setting the exception on 
the exchange. Take over through the
+            // reactive executor, after the work already queued there, so a 
callback that the processor handed on and
+            // that this queued work calls still comes first and continues the 
exchange (a callback that only work
+            // queued after the takeover calls loses to it, and is ignored)
+            reactiveExecutor.schedule(() -> afterTask.takeOver(call, e));
+            sync = false;
+        }
         if (!sync) {
             // Deprecated since 4.19.0
             
EventHelper.notifyExchangeAsyncProcessingStartedEvent(camelContext, exchange);
diff --git 
a/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/DefaultChannel.java
 
b/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/DefaultChannel.java
index efc46cb63f30..28f45d41c63a 100644
--- 
a/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/DefaultChannel.java
+++ 
b/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/DefaultChannel.java
@@ -131,6 +131,9 @@ public class DefaultChannel extends CamelInternalProcessor 
implements Channel {
 
         // the output has now been created, so assign the output as the 
processor
         setProcessor(getOutput());
+        // a step with no error handler (such as in a doTry or an onException) 
is called directly, so this channel
+        // catches what its processor throws instead of calling the callback
+        catchProcessorThrows = errorHandler == null;
         ServiceHelper.startService(errorHandler, output);
     }
 
diff --git 
a/core/camel-core/src/test/java/org/apache/camel/processor/StepWithoutErrorHandlerProcessorThrowsPooledTest.java
 
b/core/camel-core/src/test/java/org/apache/camel/processor/StepWithoutErrorHandlerProcessorThrowsPooledTest.java
new file mode 100644
index 000000000000..83ab84acedd9
--- /dev/null
+++ 
b/core/camel-core/src/test/java/org/apache/camel/processor/StepWithoutErrorHandlerProcessorThrowsPooledTest.java
@@ -0,0 +1,38 @@
+/*
+ * 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;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.ExtendedCamelContext;
+import org.apache.camel.impl.engine.PooledExchangeFactory;
+import org.apache.camel.impl.engine.PooledProcessorExchangeFactory;
+
+/**
+ * {@link StepWithoutErrorHandlerProcessorThrowsTest} with pooled exchanges, 
so the channels use pooled after tasks,
+ * which are reset and reused when they are released.
+ */
+class StepWithoutErrorHandlerProcessorThrowsPooledTest extends 
StepWithoutErrorHandlerProcessorThrowsTestSupport {
+
+    @Override
+    protected CamelContext createCamelContext() throws Exception {
+        CamelContext camelContext = super.createCamelContext();
+        ExtendedCamelContext ecc = camelContext.getCamelContextExtension();
+        ecc.setExchangeFactory(new PooledExchangeFactory());
+        ecc.setProcessorExchangeFactory(new PooledProcessorExchangeFactory());
+        return camelContext;
+    }
+}
diff --git 
a/core/camel-core/src/test/java/org/apache/camel/processor/StepWithoutErrorHandlerProcessorThrowsTest.java
 
b/core/camel-core/src/test/java/org/apache/camel/processor/StepWithoutErrorHandlerProcessorThrowsTest.java
new file mode 100644
index 000000000000..130e4df1944d
--- /dev/null
+++ 
b/core/camel-core/src/test/java/org/apache/camel/processor/StepWithoutErrorHandlerProcessorThrowsTest.java
@@ -0,0 +1,226 @@
+/*
+ * 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;
+
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.apache.camel.AsyncCallback;
+import org.apache.camel.Channel;
+import org.apache.camel.Exchange;
+import org.apache.camel.Processor;
+import org.apache.camel.support.AsyncProcessorSupport;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+class StepWithoutErrorHandlerProcessorThrowsTest extends 
StepWithoutErrorHandlerProcessorThrowsTestSupport {
+
+    @Test
+    void doTry() throws Exception {
+        assertSameAsSettingTheException(DO_TRY,
+                "exception=none caught=IllegalStateException:thrown result=0 
caughtMock=1 finally=0 calls=1");
+    }
+
+    @Test
+    void doTryWithDoFinally() throws Exception {
+        assertSameAsSettingTheException(DO_TRY_FINALLY,
+                "exception=IllegalStateException:thrown 
caught=IllegalStateException:thrown result=0 caughtMock=0 finally=1 calls=1");
+    }
+
+    @Test
+    void doTryWithDoFinallyProcessorThrowsError() throws Exception {
+        // an Error (such as NoClassDefFoundError) thrown before the callback 
fails the exchange as well, as in the
+        // other fixes of this kind
+        start(DO_TRY_FINALLY, new AsyncProcessorSupport() {
+            @Override
+            public boolean process(Exchange exchange, AsyncCallback callback) {
+                calls.incrementAndGet();
+                throw new NoClassDefFoundError("thrown");
+            }
+        });
+        Exchange out = send();
+        assertInstanceOf(NoClassDefFoundError.class, 
out.getException().getCause());
+        assertEquals("thrown", out.getException().getCause().getMessage());
+        assertEquals(1, calls.get(), "calls");
+        assertEquals(0, 
getMockEndpoint("mock:start-result").getReceivedCounter(), "mock:result");
+        assertEquals(1, 
getMockEndpoint("mock:start-finally").getReceivedCounter(), "mock:finally");
+    }
+
+    @Test
+    void doCatch() throws Exception {
+        assertSameAsSettingTheException(DO_CATCH,
+                "exception=IllegalStateException:thrown 
caught=IllegalStateException:thrown result=0 caughtMock=0"
+                                                  + " finally=1 calls=1");
+    }
+
+    @Test
+    void doFinally() throws Exception {
+        assertSameAsSettingTheException(DO_FINALLY,
+                "exception=IllegalStateException:thrown caught=none result=0 
caughtMock=0 finally=0 calls=1");
+    }
+
+    @Test
+    void onException() throws Exception {
+        assertSameAsSettingTheException(ON_EXCEPTION,
+                "exception=IllegalStateException:thrown 
caught=IllegalStateException:thrown result=0 caughtMock=0"
+                                                      + " finally=0 calls=1");
+    }
+
+    @Test
+    void onExceptionNotHandled() throws Exception {
+        assertSameAsSettingTheException(ON_EXCEPTION_NOT_HANDLED,
+                "exception=IllegalStateException:thrown 
caught=IllegalStateException:thrown result=0 caughtMock=0"
+                                                                  + " 
finally=0 calls=1");
+    }
+
+    @Test
+    void multicastOutput() throws Exception {
+        // the failed output fails the exchange at the end of the multicast, 
and the next output still runs
+        assertSameAsSettingTheException(MULTICAST,
+                "exception=IllegalStateException:thrown caught=none result=0 
caughtMock=0 finally=1 calls=1");
+    }
+
+    @Test
+    void multicastOutputRedelivered() throws Exception {
+        // the output is redelivered as when it sets the exception, and 
succeeds on the second call
+        assertSameAsSettingTheException(MULTICAST_REDELIVERY,
+                "exception=none caught=none result=1 caughtMock=0 finally=1 
calls=2");
+    }
+
+    @Test
+    void afterAnAsynchronousStep() throws Exception {
+        // the step runs on the thread of the asynchronous delay, not on the 
caller's
+        start((b, id, p) -> b
+                .from("direct:" + id)
+                .doTry()
+                    .delay(10).asyncDelayed().process(p).to("mock:" + id + 
"-result")
+                .endDoTry()
+                .doCatch(IllegalStateException.class)
+                    .to("mock:" + id + "-caught")
+                .end(), new ThrowsFirst());
+
+        assertEquals("exception=none caught=IllegalStateException:thrown 
result=0 caughtMock=1 finally=0 calls=1",
+                outcome(send()));
+    }
+
+    @Test
+    void withMdcLogging() throws Exception {
+        // with MDC logging the unit of work wraps the callback of each step 
(the takeover runs its after processing
+        // instead of that wrapper)
+        context.setUseMDCLogging(true);
+        start(DO_TRY, new ThrowsFirst());
+
+        assertEquals("exception=none caught=IllegalStateException:thrown 
result=0 caughtMock=1 finally=0 calls=1",
+                outcome(send()));
+    }
+
+    @Test
+    void controlTransacted() throws Exception {
+        // control: a transacted exchange calls the step synchronously, which 
catches the exception, on main too
+        start(DO_TRY, new ThrowsFirst());
+
+        Exchange out = template.asyncSend("direct:start", e -> {
+            e.getMessage().setBody("Hello");
+            e.getExchangeExtension().setTransacted(true);
+        }).get(10, TimeUnit.SECONDS);
+        assertEquals("exception=none caught=IllegalStateException:thrown 
result=0 caughtMock=1 finally=0 calls=1",
+                outcome(out));
+    }
+
+    @Test
+    void controlPlainProcessorThrows() throws Exception {
+        // control, as before: the converter of a plain Processor to an 
AsyncProcessor catches the exception and sets it
+        // on the exchange
+        start(DO_TRY, exchange -> {
+            calls.incrementAndGet();
+            throw new IllegalStateException("thrown");
+        });
+
+        assertEquals("exception=none caught=IllegalStateException:thrown 
result=0 caughtMock=1 finally=0 calls=1",
+                outcome(send()));
+    }
+
+    @Test
+    void controlCallbackCalledBeforeTheProcessorThrows() throws Exception {
+        // control, as before: the processor calls the callback and then 
throws. The callback continued the exchange, so
+        // the exception is left to the caller (the reactive executor, which 
logs it), and the exchange completes once
+        start(DO_TRY, new AsyncProcessorSupport() {
+            @Override
+            public boolean process(Exchange exchange, AsyncCallback callback) {
+                calls.incrementAndGet();
+                callback.done(true);
+                throw new IllegalStateException("after the callback");
+            }
+        });
+        AtomicInteger done = countDoneExchanges();
+
+        Exchange out = sendOnThisThread();
+        assertEquals("exception=none caught=none result=1 caughtMock=0 
finally=0 calls=1", outcome(out));
+        assertEquals(1, done.get(), "the exchange was done once");
+    }
+
+    @Test
+    void 
controlCallbackCalledBeforeTheProcessorThrowsReachesASynchronousCaller() throws 
Exception {
+        // control, as before: a caller that calls the step through the 
synchronous API (as a circuit breaker does with
+        // its steps) gets the exception that the processor throws after it 
called the callback, as the callback only
+        // released that caller
+        Processor processor = new AsyncProcessorSupport() {
+            @Override
+            public boolean process(Exchange exchange, AsyncCallback callback) {
+                calls.incrementAndGet();
+                callback.done(true);
+                throw new IllegalStateException("after the callback");
+            }
+        };
+        start(DO_TRY, processor);
+        Channel channel = findChannel(context.getRoutes().get(0).navigate(), 
processor);
+        assertNotNull(channel, "the channel of the step");
+
+        Exchange exchange = 
context.getEndpoint("direct:start").createExchange();
+        IllegalStateException e = assertThrows(IllegalStateException.class, () 
-> channel.process(exchange));
+        assertEquals("after the callback", e.getMessage());
+        assertEquals(1, calls.get(), "calls");
+    }
+
+    @Test
+    void controlCallbackQueuedBeforeTheProcessorThrows() throws Exception {
+        // control, as before: the processor queues the call of the callback 
on the reactive executor and then throws.
+        // The thrown failure is taken over through the reactive executor, 
after the work already queued there, so the
+        // queued callback comes first and continues the exchange
+        start(DO_TRY, new AsyncProcessorSupport() {
+            @Override
+            public boolean process(Exchange exchange, AsyncCallback callback) {
+                calls.incrementAndGet();
+                
exchange.getContext().getCamelContextExtension().getReactiveExecutor().schedule(()
 -> {
+                    exchange.getMessage().setBody("queued");
+                    callback.done(false);
+                });
+                throw new IllegalStateException("after the callback was 
queued");
+            }
+        });
+        AtomicInteger done = countDoneExchanges();
+
+        Exchange out = sendOnThisThread();
+        assertEquals("exception=none caught=none result=1 caughtMock=0 
finally=0 calls=1", outcome(out));
+        assertEquals("queued", out.getMessage().getBody());
+        assertEquals(1, done.get(), "the exchange was done once");
+    }
+}
diff --git 
a/core/camel-core/src/test/java/org/apache/camel/processor/StepWithoutErrorHandlerProcessorThrowsTestSupport.java
 
b/core/camel-core/src/test/java/org/apache/camel/processor/StepWithoutErrorHandlerProcessorThrowsTestSupport.java
new file mode 100644
index 000000000000..7992db855d3f
--- /dev/null
+++ 
b/core/camel-core/src/test/java/org/apache/camel/processor/StepWithoutErrorHandlerProcessorThrowsTestSupport.java
@@ -0,0 +1,453 @@
+/*
+ * 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;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.apache.camel.AsyncCallback;
+import org.apache.camel.Channel;
+import org.apache.camel.ContextTestSupport;
+import org.apache.camel.Exchange;
+import org.apache.camel.Navigate;
+import org.apache.camel.Processor;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.spi.CamelEvent;
+import org.apache.camel.spi.CamelEvent.ExchangeCompletedEvent;
+import org.apache.camel.spi.CamelEvent.ExchangeFailedEvent;
+import org.apache.camel.support.AsyncProcessorSupport;
+import org.apache.camel.support.EventNotifierSupport;
+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.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+abstract class StepWithoutErrorHandlerProcessorThrowsTestSupport extends 
ContextTestSupport {
+
+    final AtomicInteger calls = new AtomicInteger();
+
+    @Override
+    public boolean isUseRouteBuilder() {
+        return false;
+    }
+
+    /**
+     * Throws on the first call, and succeeds on the later calls.
+     */
+    final class ThrowsFirst extends AsyncProcessorSupport {
+        @Override
+        public boolean process(Exchange exchange, AsyncCallback callback) {
+            if (calls.incrementAndGet() == 1) {
+                throw new IllegalStateException("thrown");
+            }
+            callback.done(true);
+            return true;
+        }
+    }
+
+    /**
+     * Routes from direct:{id}, to the mocks {id}-result, {id}-caught and 
{id}-finally, so the processor that throws and
+     * the one that sets the exception can run side by side.
+     */
+    interface Routes {
+        void configure(RouteBuilder builder, String id, Processor processor);
+    }
+
+    static final Routes DO_TRY = (b, id, p) -> b
+            .from("direct:" + id)
+            .doTry()
+                .process(p).to("mock:" + id + "-result")
+            .doCatch(IllegalStateException.class)
+                .to("mock:" + id + "-caught")
+            .end();
+
+    static final Routes DO_TRY_FINALLY = (b, id, p) -> b
+            .from("direct:" + id)
+            .doTry()
+                .process(p).to("mock:" + id + "-result")
+            .doFinally()
+                .to("mock:" + id + "-finally")
+            .end();
+
+    static final Routes DO_CATCH = (b, id, p) -> b
+            .from("direct:" + id)
+            .doTry()
+                .throwException(new IllegalArgumentException("failed"))
+            .doCatch(IllegalArgumentException.class)
+                .process(p).to("mock:" + id + "-result")
+            .doFinally()
+                .to("mock:" + id + "-finally")
+            .end();
+
+    static final Routes DO_FINALLY = (b, id, p) -> b
+            .from("direct:" + id)
+            .doTry()
+                .to("mock:" + id + "-try")
+            .doFinally()
+                .process(p).to("mock:" + id + "-result")
+            .end();
+
+    static final Routes ON_EXCEPTION = (b, id, p) -> b
+            .from("direct:" + id)
+            .onException(IllegalArgumentException.class).handled(true)
+                .process(p).to("mock:" + id + "-result")
+            .end()
+            .throwException(new IllegalArgumentException("failed"));
+
+    static final Routes ON_EXCEPTION_NOT_HANDLED = (b, id, p) -> b
+            .from("direct:" + id)
+            .onException(IllegalArgumentException.class)
+                .process(p).to("mock:" + id + "-result")
+            .end()
+            .throwException(new IllegalArgumentException("failed"));
+
+    // the outputs of a multicast have no error handler in their channel (the 
multicast wraps each one in a clone of
+    // the error handler of the route); the second output is the mock 
{id}-finally, so the outcome shows whether it ran
+    static final Routes MULTICAST = (b, id, p) -> b
+            .from("direct:" + id)
+            .multicast()
+                .process(p)
+                .to("mock:" + id + "-finally")
+            .end()
+            .to("mock:" + id + "-result");
+
+    // the clone of the error handler around each output of a multicast 
redelivers it
+    static final Routes MULTICAST_REDELIVERY = (b, id, p) -> b
+            .from("direct:" + id)
+            
.errorHandler(b.defaultErrorHandler().maximumRedeliveries(2).redeliveryDelay(0))
+            .multicast()
+                .process(p)
+                .to("mock:" + id + "-finally")
+            .end()
+            .to("mock:" + id + "-result");
+
+    void start(Routes routes, Processor processor) throws Exception {
+        context.addRoutes(new RouteBuilder() {
+            @Override
+            public void configure() {
+                routes.configure(this, "start", processor);
+            }
+        });
+        context.start();
+    }
+
+    Exchange send() throws Exception {
+        return asyncSend().get(10, TimeUnit.SECONDS);
+    }
+
+    CompletableFuture<Exchange> asyncSend() {
+        // bounded by the caller: on a hang the future does not complete and 
get times out
+        return template.asyncSend("direct:start", e -> 
e.getMessage().setBody("Hello"));
+    }
+
+    /**
+     * Sends on this thread, which runs the reactive work of the exchange 
until none is left: when it returns, the
+     * takeover of a thrown failure has run too.
+     */
+    Exchange sendOnThisThread() {
+        return template.send("direct:start", e -> 
e.getMessage().setBody("Hello"));
+    }
+
+    /**
+     * Counts the exchanges done: a second continuation of an exchange would 
complete its unit of work again.
+     */
+    AtomicInteger countDoneExchanges() {
+        AtomicInteger done = new AtomicInteger();
+        context.getManagementStrategy().addEventNotifier(new 
EventNotifierSupport() {
+            @Override
+            public void notify(CamelEvent event) {
+                done.incrementAndGet();
+            }
+
+            @Override
+            public boolean isEnabled(CamelEvent event) {
+                return event instanceof ExchangeCompletedEvent || event 
instanceof ExchangeFailedEvent;
+            }
+        });
+        return done;
+    }
+
+    /**
+     * What a route does with an exchange: the failure, the caught exception 
and the mocks reached.
+     */
+    String outcome(Exchange out) {
+        return outcome(out, "start", calls);
+    }
+
+    String outcome(Exchange out, String id, AtomicInteger calls) {
+        Exception caught = out.getProperty(Exchange.EXCEPTION_CAUGHT, 
Exception.class);
+        return "exception=" + describe(out.getException()) + " caught=" + 
describe(caught)
+               + " result=" + getMockEndpoint("mock:" + id + 
"-result").getReceivedCounter()
+               + " caughtMock=" + getMockEndpoint("mock:" + id + 
"-caught").getReceivedCounter()
+               + " finally=" + getMockEndpoint("mock:" + id + 
"-finally").getReceivedCounter()
+               + " calls=" + calls.get();
+    }
+
+    static String describe(Throwable t) {
+        return t == null ? "none" : t.getClass().getSimpleName() + ":" + 
t.getMessage();
+    }
+
+    /**
+     * The route with the processor that sets the exception on the exchange 
(from direct:sets) and with the one that
+     * throws (from direct:start) must do the same.
+     */
+    void assertSameAsSettingTheException(Routes routes, String expected) 
throws Exception {
+        AtomicInteger setsCalls = new AtomicInteger();
+        Processor sets = new AsyncProcessorSupport() {
+            @Override
+            public boolean process(Exchange exchange, AsyncCallback callback) {
+                if (setsCalls.incrementAndGet() == 1) {
+                    exchange.setException(new IllegalStateException("thrown"));
+                }
+                callback.done(true);
+                return true;
+            }
+        };
+        context.addRoutes(new RouteBuilder() {
+            @Override
+            public void configure() {
+                routes.configure(this, "sets", sets);
+                routes.configure(this, "start", new ThrowsFirst());
+            }
+        });
+        context.start();
+
+        // the exchanges done (with the copies of a multicast): a second 
continuation would complete one again
+        AtomicInteger done = countDoneExchanges();
+        Exchange reference = template.asyncSend("direct:sets", e -> 
e.getMessage().setBody("Hello")).get(10,
+                TimeUnit.SECONDS);
+        assertEquals(expected, outcome(reference, "sets", setsCalls), "the 
processor that sets the exception");
+        await().atMost(10, TimeUnit.SECONDS).until(() -> done.get() >= 1);
+        int referenceDone = done.getAndSet(0);
+
+        Exchange out = send();
+        assertEquals(expected, outcome(out), "the processor that throws");
+        await().atMost(10, TimeUnit.SECONDS).until(() -> done.get() >= 
referenceDone);
+        assertEquals(referenceDone, done.get(), "the exchanges done, as with 
the processor that sets the exception");
+    }
+
+    @Test
+    void nextExchangeIsProcessed() throws Exception {
+        start(DO_TRY, new ThrowsFirst());
+        assertEquals("exception=none caught=IllegalStateException:thrown 
result=0 caughtMock=1 finally=0 calls=1",
+                outcome(send()));
+
+        Exchange out = send();
+        assertNull(out.getException());
+        assertNull(out.getProperty(Exchange.EXCEPTION_CAUGHT));
+        assertEquals(1, 
getMockEndpoint("mock:start-result").getReceivedCounter(), "mock:result");
+        assertEquals(1, 
getMockEndpoint("mock:start-caught").getReceivedCounter(), "mock:caught");
+        assertEquals(2, calls.get(), "calls");
+    }
+
+    static Channel findChannel(Navigate<Processor> nav, Processor processor) {
+        if (nav == null || !nav.hasNext()) {
+            return null;
+        }
+        for (Processor p : nav.next()) {
+            if (p instanceof Channel channel && channel.getNextProcessor() == 
processor) {
+                return channel;
+            }
+            if (p instanceof Navigate<?>) {
+                @SuppressWarnings("unchecked")
+                Channel found = findChannel((Navigate<Processor>) p, 
processor);
+                if (found != null) {
+                    return found;
+                }
+            }
+        }
+        return null;
+    }
+
+    @Test
+    void callbackCalledAfterTheProcessorThrewIsIgnored() throws Exception {
+        // the processor hands the callback to another thread and then throws. 
The step cannot tell this from a callback
+        // that never comes, so it fails the exchange with the thrown 
exception (before, the exchange hung), and the
+        // late callback must not continue the exchange a second time
+        CountDownLatch release = new CountDownLatch(1);
+        AtomicReference<Thread> late = new AtomicReference<>();
+        AtomicReference<Throwable> lateFailure = new AtomicReference<>();
+        start(DO_TRY, new AsyncProcessorSupport() {
+            @Override
+            public boolean process(Exchange exchange, AsyncCallback callback) {
+                calls.incrementAndGet();
+                Thread thread = new Thread(() -> {
+                    try {
+                        if (release.await(10, TimeUnit.SECONDS)) {
+                            callback.done(false);
+                        }
+                    } catch (Throwable t) {
+                        lateFailure.set(t);
+                    }
+                }, "late-callback");
+                late.set(thread);
+                thread.start();
+                throw new IllegalStateException("thrown");
+            }
+        });
+        AtomicInteger done = countDoneExchanges();
+
+        assertEquals("exception=none caught=IllegalStateException:thrown 
result=0 caughtMock=1 finally=0 calls=1",
+                outcome(send()));
+
+        // now the processor calls the callback, after the exchange completed
+        release.countDown();
+        late.get().join(TimeUnit.SECONDS.toMillis(10));
+        assertFalse(late.get().isAlive(), "the late callback thread ended");
+        assertNull(lateFailure.get(), "the late callback failed");
+        assertEquals(1, done.get(), "the exchange was done once");
+        assertEquals(0, 
getMockEndpoint("mock:start-result").getReceivedCounter(), "mock:result");
+        assertEquals(1, 
getMockEndpoint("mock:start-caught").getReceivedCounter(), "mock:caught");
+    }
+
+    @Test
+    void callbackCalledAfterTheProcessorThrewDoesNotContinueTheNextExchange() 
throws Exception {
+        // as above, but the late callback comes while the next exchange waits 
for the callback of its own call of the
+        // processor: it must not continue that exchange
+        CountDownLatch releaseFirst = new CountDownLatch(1);
+        CountDownLatch releaseSecond = new CountDownLatch(1);
+        AtomicReference<Thread> first = new AtomicReference<>();
+        AtomicReference<Thread> second = new AtomicReference<>();
+        AtomicReference<Throwable> lateFailure = new AtomicReference<>();
+        start(DO_TRY, new AsyncProcessorSupport() {
+            @Override
+            public boolean process(Exchange exchange, AsyncCallback callback) {
+                int call = calls.incrementAndGet();
+                CountDownLatch release = call == 1 ? releaseFirst : 
releaseSecond;
+                Thread thread = new Thread(() -> {
+                    try {
+                        if (release.await(10, TimeUnit.SECONDS)) {
+                            callback.done(false);
+                        }
+                    } catch (Throwable t) {
+                        lateFailure.set(t);
+                    }
+                }, "callback-" + call);
+                (call == 1 ? first : second).set(thread);
+                thread.start();
+                if (call == 1) {
+                    throw new IllegalStateException("thrown");
+                }
+                return false;
+            }
+        });
+
+        assertEquals("exception=none caught=IllegalStateException:thrown 
result=0 caughtMock=1 finally=0 calls=1",
+                outcome(send()));
+
+        CompletableFuture<Exchange> next = asyncSend();
+        await().atMost(10, TimeUnit.SECONDS).until(() -> second.get() != null);
+
+        // the callback of the first call, while the second call waits for its 
own
+        releaseFirst.countDown();
+        first.get().join(TimeUnit.SECONDS.toMillis(10));
+        assertFalse(first.get().isAlive(), "the late callback thread ended");
+        assertFalse(next.isDone(), "the late callback of the first exchange 
continued the next exchange");
+        assertEquals(0, 
getMockEndpoint("mock:start-result").getReceivedCounter(), "mock:result");
+
+        releaseSecond.countDown();
+        Exchange out = next.get(10, TimeUnit.SECONDS);
+        assertNull(out.getException());
+        assertEquals(1, 
getMockEndpoint("mock:start-result").getReceivedCounter(), "mock:result");
+        second.get().join(TimeUnit.SECONDS.toMillis(10));
+        assertNull(lateFailure.get(), "a callback failed");
+        assertEquals(2, calls.get(), "calls");
+    }
+
+    @Test
+    void callbackAndThrowAtTheSameTimeContinueEachExchangeOnce() throws 
Exception {
+        // the processor hands the callback to another thread, which calls it 
at once, and throws: the callback and the
+        // takeover race, and whichever wins, each exchange is continued once 
(as a success, or caught by the doCatch)
+        int count = 50;
+        ExecutorService callbacks = Executors.newFixedThreadPool(2);
+        try {
+            start(DO_TRY, new AsyncProcessorSupport() {
+                @Override
+                public boolean process(Exchange exchange, AsyncCallback 
callback) {
+                    calls.incrementAndGet();
+                    callbacks.execute(() -> callback.done(false));
+                    throw new IllegalStateException("thrown");
+                }
+            });
+            AtomicInteger done = countDoneExchanges();
+
+            List<CompletableFuture<Exchange>> sends = new ArrayList<>();
+            for (int i = 0; i < count; i++) {
+                sends.add(asyncSend());
+            }
+            int succeeded = 0;
+            int caught = 0;
+            for (CompletableFuture<Exchange> send : sends) {
+                Exchange out = send.get(20, TimeUnit.SECONDS);
+                assertNull(out.getException());
+                Exception e = out.getProperty(Exchange.EXCEPTION_CAUGHT, 
Exception.class);
+                if (e == null) {
+                    succeeded++;
+                } else {
+                    assertEquals("thrown", e.getMessage());
+                    caught++;
+                }
+            }
+            await().atMost(10, TimeUnit.SECONDS).until(() -> done.get() >= 
count);
+            // the callbacks that lost have run too
+            callbacks.shutdown();
+            assertTrue(callbacks.awaitTermination(10, TimeUnit.SECONDS), "the 
callbacks ran");
+
+            assertEquals(count, done.get(), "each exchange was done once");
+            assertEquals(succeeded, 
getMockEndpoint("mock:start-result").getReceivedCounter(), "mock:result");
+            assertEquals(caught, 
getMockEndpoint("mock:start-caught").getReceivedCounter(), "mock:caught");
+            assertEquals(count, calls.get(), "calls");
+        } finally {
+            callbacks.shutdownNow();
+        }
+    }
+
+    @Test
+    void callbackCalledTwiceContinuesTheExchangeOnce() throws Exception {
+        // a processor that calls its callback twice (also a broken 
processor): the second call is ignored
+        AtomicReference<Throwable> secondCall = new AtomicReference<>();
+        start(DO_TRY, new AsyncProcessorSupport() {
+            @Override
+            public boolean process(Exchange exchange, AsyncCallback callback) {
+                calls.incrementAndGet();
+                callback.done(true);
+                try {
+                    callback.done(true);
+                } catch (Throwable t) {
+                    secondCall.set(t);
+                }
+                return true;
+            }
+        });
+        AtomicInteger done = countDoneExchanges();
+
+        Exchange out = sendOnThisThread();
+        assertEquals("exception=none caught=none result=1 caughtMock=0 
finally=0 calls=1", outcome(out));
+        assertNull(secondCall.get(), "the second call of the callback failed");
+        assertEquals(1, done.get(), "the exchange was done once");
+    }
+}
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 6ce3498cfcdb..435bf93c39fe 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
@@ -434,6 +434,21 @@ caller), put the EIP in a `doTry` with only a `doFinally` 
in the called route, o
 called route with `errorHandler(noErrorHandler())`, which turns it off for the 
other steps of that route too. Neither
 option brings back the redelivery of a whole routing slip or dynamic router.
 
+=== camel-core - an AsyncProcessor that throws in a step without an error 
handler fails the exchange
+
+A step that has no error handler of its own (the steps of `doTry`, `doCatch`, 
`doFinally` and `onException`, also when
+nested in another EIP inside them, the outputs of a multicast, the steps of a 
circuit breaker, and a step with
+`inheritErrorHandler(false)`) now handles a processor that throws from 
`process(exchange, callback)` instead of
+setting the exception on the exchange as if it had set it: the exchange fails 
with that exception, so the `doCatch`
+catches it, and the output of a multicast is redelivered by the error handler 
of the route as for any other failure.
+Before, the exchange hung.
+
+When such a processor handed its callback to another thread and then throws, 
the exchange now fails with the thrown
+exception unless that thread calls the callback first, and a later call is 
ignored and logged at WARN; before, the
+exception was dropped and that callback continued the exchange as a success. A 
second call of the callback is ignored
+and logged at WARN; before, the exchange was continued a second time. There is 
no option for the old behaviour; fix the
+processor to set the exception on the exchange and call the callback, as an 
`AsyncProcessor` must.
+
 === camel-core - variableReceive into a route or group variable stores the 
header variables in that route or group
 
 With `variableReceive` (`toV`, `enrich`, `pollEnrich`, `fromV` and so on) into 
a route variable or a group variable,

Reply via email to