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

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


The following commit(s) were added to refs/heads/camel-4.22.x by this push:
     new 66d6fb57c679 CAMEL-24811: camel-servlet - fix async servlet completing 
before the route finishes (backport 4.22.x)
66d6fb57c679 is described below

commit 66d6fb57c6798934d9ac3c6e5cce74a9241db8f2
Author: Federico Mariani <[email protected]>
AuthorDate: Fri Sep 18 21:18:06 2026 +0200

    CAMEL-24811: camel-servlet - fix async servlet completing before the route 
finishes (backport 4.22.x)
    
    Backport of #26584 to camel-4.22.x (straight cherry-pick, no conflicts).
    
    CamelServlet.doServiceAsync() (async=true without executorRef, and
    camel-jetty with async=true&useContinuation=false) completed the
    AsyncContext before a route that resumes on another thread had written
    its response. The bug dates from CAMEL-11731 (Camel 3.7), so every LTS
    branch is affected.
    
    No API changes: doService() keeps its signature, the in-flight stage is
    handed over via a protected request attribute constant. Tests cover the
    no-executorRef race in camel-servlet and the delayed route in camel-jetty.
    
    Closes #26596
    
    Co-Authored-By: Claude Sonnet 5 <[email protected]>
    Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
---
 .../org/apache/camel/http/common/CamelServlet.java | 58 +++++++++----
 .../jetty/JettyAsyncDelayedRouteTest.java          | 48 +++++++++++
 .../component/servlet/ServletAsyncErrorTest.java   | 98 ++++++++++++++++++++++
 .../servlet/ServletAsyncNoExecutorRefRaceTest.java | 56 +++++++++++++
 .../servlet/example-camelContext-race.xml          | 44 ++++++++++
 5 files changed, 289 insertions(+), 15 deletions(-)

diff --git 
a/components/camel-http-common/src/main/java/org/apache/camel/http/common/CamelServlet.java
 
b/components/camel-http-common/src/main/java/org/apache/camel/http/common/CamelServlet.java
index 9826a69ab6fe..8f329bcec9d4 100644
--- 
a/components/camel-http-common/src/main/java/org/apache/camel/http/common/CamelServlet.java
+++ 
b/components/camel-http-common/src/main/java/org/apache/camel/http/common/CamelServlet.java
@@ -61,6 +61,12 @@ public class CamelServlet extends HttpServlet implements 
HttpRegistryProvider {
     public static final String EXECUTOR_REF_PARAM = "executorRef";
     public static final List<String> METHODS
             = Arrays.asList("GET", "HEAD", "POST", "PUT", "DELETE", "TRACE", 
"OPTIONS", "CONNECT", "PATCH");
+    /**
+     * Request attribute holding the {@link CompletionStage} of a request 
still being processed on another thread when
+     * {@link #doService(HttpServletRequest, HttpServletResponse)} returns. 
The {@link AsyncContext} must not be
+     * completed before it.
+     */
+    protected static final String ASYNC_PROMISE_ATTRIBUTE_NAME = 
"CamelAsyncPromise";
 
     private static final long serialVersionUID = -7061982839117697829L;
 
@@ -143,14 +149,25 @@ public class CamelServlet extends HttpServlet implements 
HttpRegistryProvider {
             HttpServletRequest req, HttpServletResponse resp, HttpConsumer 
consumer, AsyncContext context) {
         try {
             final CompletionStage<?> promise = doExecute(req, resp, consumer);
-            if (promise == null) { // early quit
+            completeOnCompletion(context, promise);
+        } catch (Exception e) {
+            try {
+                onError(resp, e);
+            } finally {
                 context.complete();
-            } else {
-                promise.whenComplete((r, e) -> context.complete());
             }
-        } catch (Exception e) {
-            onError(resp, e);
+        }
+    }
+
+    /**
+     * Completes the {@link AsyncContext} once the promise is done, or 
immediately if the request was handled
+     * synchronously (no promise).
+     */
+    private static void completeOnCompletion(AsyncContext context, 
CompletionStage<?> promise) {
+        if (promise == null) {
             context.complete();
+        } else {
+            promise.whenComplete((r, e) -> context.complete());
         }
     }
 
@@ -215,11 +232,16 @@ public class CamelServlet extends HttpServlet implements 
HttpRegistryProvider {
         final HttpServletResponse response = (HttpServletResponse) 
context.getResponse();
         try {
             doService(request, response);
+            // doService is void (overridden by subclasses) so in-flight 
processing is handed over via the request
+            final CompletionStage<?> promise = (CompletionStage<?>) 
request.getAttribute(ASYNC_PROMISE_ATTRIBUTE_NAME);
+            completeOnCompletion(context, promise);
         } catch (Exception e) {
             //An error shouldn't occur as we should handle most of error in 
doService
-            onError(response, e);
-        } finally {
-            context.complete();
+            try {
+                onError(response, e);
+            } finally {
+                context.complete();
+            }
         }
     }
 
@@ -234,7 +256,11 @@ public class CamelServlet extends HttpServlet implements 
HttpRegistryProvider {
         log.trace("Service: {}", request);
         HttpConsumer consumer = doResolve(request, response);
         if (consumer != null) {
-            doExecute(request, response, consumer);
+            CompletionStage<?> promise = doExecute(request, response, 
consumer);
+            if (promise != null) {
+                // still in-flight on another thread, which will write the 
response
+                request.setAttribute(ASYNC_PROMISE_ATTRIBUTE_NAME, promise);
+            }
         }
     }
 
@@ -325,6 +351,8 @@ public class CamelServlet extends HttpServlet implements 
HttpRegistryProvider {
             }
         } catch (Exception e) {
             exchange.setException(e);
+            // processAsync failed synchronously so write the response here
+            isAsync = false;
         }
 
         try {
@@ -373,12 +401,12 @@ public class CamelServlet extends HttpServlet implements 
HttpRegistryProvider {
                 .whenComplete((r, ex) -> {
                     if (ex != null) {
                         exchange.setException(ex);
-                    } else {
-                        try {
-                            afterProcess(res, consumer, exchange, false);
-                        } catch (Exception e) {
-                            exchange.setException(e);
-                        }
+                    }
+                    // always write the response (error or not) and finish the 
UoW
+                    try {
+                        afterProcess(res, consumer, exchange, false);
+                    } catch (Exception e) {
+                        exchange.setException(e);
                     }
                 });
         return result;
diff --git 
a/components/camel-jetty/src/test/java/org/apache/camel/component/jetty/JettyAsyncDelayedRouteTest.java
 
b/components/camel-jetty/src/test/java/org/apache/camel/component/jetty/JettyAsyncDelayedRouteTest.java
new file mode 100644
index 000000000000..3d56c3c1a35a
--- /dev/null
+++ 
b/components/camel-jetty/src/test/java/org/apache/camel/component/jetty/JettyAsyncDelayedRouteTest.java
@@ -0,0 +1,48 @@
+/*
+ * 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.component.jetty;
+
+import org.apache.camel.builder.RouteBuilder;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * async=true must wait for a route step that resumes on another thread before 
completing the request.
+ */
+public class JettyAsyncDelayedRouteTest extends BaseJettyTest {
+
+    @Test
+    public void testAsyncRouteCompletesBeforeResponse() {
+        String body = template.requestBody("http://localhost:{{port}}/racy";, 
"hello", String.class);
+        assertEquals("delayed-response", body);
+    }
+
+    @Override
+    protected RouteBuilder createRouteBuilder() {
+        return new RouteBuilder() {
+            public void configure() {
+                // async and continuation is not compatible!
+                
from("jetty:http://localhost:{{port}}/racy?async=true&useContinuation=false";)
+                        // resumes on another thread
+                        .delay(300).asyncDelayed().end()
+                        .transform().constant("delayed-response");
+            }
+        };
+    }
+
+}
diff --git 
a/components/camel-servlet/src/test/java/org/apache/camel/component/servlet/ServletAsyncErrorTest.java
 
b/components/camel-servlet/src/test/java/org/apache/camel/component/servlet/ServletAsyncErrorTest.java
new file mode 100644
index 000000000000..d72d807d6f74
--- /dev/null
+++ 
b/components/camel-servlet/src/test/java/org/apache/camel/component/servlet/ServletAsyncErrorTest.java
@@ -0,0 +1,98 @@
+/*
+ * 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.component.servlet;
+
+import java.util.concurrent.CompletableFuture;
+
+import io.undertow.servlet.Servlets;
+import io.undertow.servlet.api.DeploymentInfo;
+import org.apache.camel.AsyncCallback;
+import org.apache.camel.Exchange;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.support.AsyncProcessorSupport;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * async=true must still write an error response when the route fails after 
resuming on another thread, or when the
+ * processor's stage completes exceptionally.
+ */
+public class ServletAsyncErrorTest extends ServletCamelRouterTestSupport {
+
+    @Test
+    public void testAsyncRouteThrows() throws Exception {
+        WebResponse response = query(new GetMethodWebRequest(contextUrl + 
"/services/async-error"), false);
+
+        assertEquals(500, response.getResponseCode());
+    }
+
+    @Test
+    public void testAsyncStageCompletesExceptionally() throws Exception {
+        // a route processor never completes the stage exceptionally (the 
exception lands on the exchange), so
+        // plug a consumer with a custom AsyncProcessor directly
+        ServletEndpoint endpoint = 
context.getEndpoint("servlet:///async-failed", ServletEndpoint.class);
+        ServletConsumer consumer = (ServletConsumer) 
endpoint.createConsumer(new AsyncProcessorSupport() {
+            @Override
+            public boolean process(Exchange exchange, AsyncCallback callback) {
+                callback.done(true);
+                return true;
+            }
+
+            @Override
+            public CompletableFuture<Exchange> processAsync(Exchange exchange) 
{
+                return CompletableFuture.failedFuture(new 
IllegalStateException("stage failed"));
+            }
+        });
+        consumer.start();
+        try {
+            WebResponse response = query(new GetMethodWebRequest(contextUrl + 
"/services/async-failed"), false);
+
+            assertEquals(500, response.getResponseCode());
+        } finally {
+            consumer.stop();
+        }
+    }
+
+    @Override
+    protected DeploymentInfo getDeploymentInfo() {
+        return Servlets.deployment()
+                .setClassLoader(getClass().getClassLoader())
+                .setContextPath(CONTEXT)
+                .setDeploymentName(getClass().getName())
+                .addServlet(Servlets.servlet("CamelServlet", 
CamelHttpTransportServlet.class)
+                        .addInitParam("async", "true")
+                        .setAsyncSupported(true)
+                        .addMapping("/services/*"));
+    }
+
+    @Override
+    protected RouteBuilder createRouteBuilder() {
+        return new RouteBuilder() {
+            @Override
+            public void configure() {
+                from("servlet:///async-error")
+                        // resumes on another thread, then fails
+                        .delay(100).asyncDelayed().end()
+                        .process(e -> {
+                            throw new IllegalStateException("boom");
+                        });
+            }
+        };
+    }
+
+}
diff --git 
a/components/camel-servlet/src/test/java/org/apache/camel/component/servlet/ServletAsyncNoExecutorRefRaceTest.java
 
b/components/camel-servlet/src/test/java/org/apache/camel/component/servlet/ServletAsyncNoExecutorRefRaceTest.java
new file mode 100644
index 000000000000..c3cbaa984a80
--- /dev/null
+++ 
b/components/camel-servlet/src/test/java/org/apache/camel/component/servlet/ServletAsyncNoExecutorRefRaceTest.java
@@ -0,0 +1,56 @@
+/*
+ * 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.component.servlet;
+
+import io.undertow.servlet.Servlets;
+import io.undertow.servlet.api.DeploymentInfo;
+import org.junit.jupiter.api.Test;
+import org.springframework.web.context.ContextLoaderListener;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * async=true without executorRef must wait for a route step that resumes on 
another thread before completing.
+ */
+public class ServletAsyncNoExecutorRefRaceTest extends 
ServletCamelRouterTestSupport {
+
+    @Test
+    public void testAsyncRouteCompletesBeforeResponse() throws Exception {
+        WebRequest req = new GetMethodWebRequest(contextUrl + 
"/services/racy");
+        WebResponse response = query(req, false);
+
+        assertEquals(200, response.getResponseCode());
+        assertEquals("delayed-response", response.getText());
+    }
+
+    @Override
+    protected DeploymentInfo getDeploymentInfo() {
+        return Servlets.deployment()
+                .setClassLoader(getClass().getClassLoader())
+                .setContextPath(CONTEXT)
+                .setDeploymentName(getClass().getName())
+                .addInitParameter("contextConfigLocation",
+                        
"classpath:org/apache/camel/component/servlet/example-camelContext-race.xml")
+                .addListener(Servlets.listener(ContextLoaderListener.class))
+                .addServlet(Servlets.servlet("CamelServlet", 
CamelHttpTransportServlet.class)
+                        .addInitParam("async", "true")
+                        .setLoadOnStartup(1)
+                        .setAsyncSupported(true)
+                        .addMapping("/services/*"));
+    }
+
+}
diff --git 
a/components/camel-servlet/src/test/resources/org/apache/camel/component/servlet/example-camelContext-race.xml
 
b/components/camel-servlet/src/test/resources/org/apache/camel/component/servlet/example-camelContext-race.xml
new file mode 100644
index 000000000000..a9436e99ae1a
--- /dev/null
+++ 
b/components/camel-servlet/src/test/resources/org/apache/camel/component/servlet/example-camelContext-race.xml
@@ -0,0 +1,44 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+    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.
+
+-->
+<beans xmlns="http://www.springframework.org/schema/beans";
+       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance";
+       xmlns:camel="http://camel.apache.org/schema/spring";
+       xsi:schemaLocation="
+       http://www.springframework.org/schema/beans 
http://www.springframework.org/schema/beans/spring-beans.xsd
+       http://camel.apache.org/schema/spring 
http://camel.apache.org/schema/spring/camel-spring.xsd
+    ">
+
+   <camelContext id="camel" streamCache="true" 
xmlns="http://camel.apache.org/schema/spring"; >
+    <route id="raceRoute">
+      <!-- incoming requests from the servlet is routed -->
+      <from uri="servlet:racy"/>
+      <!-- genuinely asynchronous: resumes on the delayer's scheduled-executor 
thread,
+           simulating a downstream call (e.g. CXF SOAP client) that completes 
on a
+           background thread instead of the calling thread -->
+      <delay asyncDelayed="true">
+        <constant>300</constant>
+      </delay>
+      <transform>
+        <simple>delayed-response</simple>
+      </transform>
+    </route>
+   </camelContext>
+
+</beans>

Reply via email to