Skip to content

CAMEL-24811: camel-servlet - fix async servlet completing before the route finishes - #26584

Merged
Croway merged 1 commit into
apache:mainfrom
Croway:CAMEL-24811-servlet-async-race
Sep 18, 2026
Merged

Croway merged 1 commit into
apache:mainfrom
Croway:CAMEL-24811-servlet-async-race

Conversation

@Croway

@Croway Croway commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Summary

CamelServlet.doServiceAsync() (used when async=true and no executorRef is configured — the simplest, most common async servlet setup) called AsyncContext.complete() unconditionally right after doService() returned, discarding the CompletionStage that doExecute() returns when the route's processor genuinely completes on another thread (AsyncProcessor.processAsync()).

This races the servlet container's async completion against the later afterProcess()/writeResponse() call:

  • On Tomcat this throws IllegalStateException: The response object has been recycled and is no longer associated with this facade.
  • Otherwise, the async context completes with whatever was (or wasn't) written yet, and the client gets a stale/empty response while the route keeps running in the background.

The same applies to camel-jetty in async mode (jetty:...?async=true&useContinuation=false): CamelContinuationServlet.handleDoService() falls back to super.doService() there and ends up in the same doServiceAsync() path.

The sibling path used when executorRef is configured (doAsyncExecution()) already does this correctly — it chains AsyncContext.complete() onto promise.whenComplete(...). This bug predates 4.21/4.22: it was introduced by CAMEL-11731 (Camel 3.7) when true async processing was added to doExecute(), without updating the no-executorRef doServiceAsync() dispatch path to match. It therefore affects every current LTS branch.

Changes

  • doService() keeps its void signature (it is a protected extension point overridden by CamelContinuationServlet, CamelWebSocketServlet and third-party servlets, so changing its return type would be a source- and binary-incompatible change and not backportable). Instead, when doExecute() returns a still in-flight CompletionStage, doService() stores it in the request attribute CamelServlet.ASYNC_PROMISE_ATTRIBUTE_NAME.
  • doServiceAsync() reads that attribute after doService() returns and completes the AsyncContext only once the stage completes, mirroring doAsyncExecution(). Because the hand-over is via the request rather than the return value, it also works when a subclass (jetty) delegates to super.doService() and drops the result.
  • doAsyncExecution()'s error branch now completes the AsyncContext in a finally: onError() always throws, so previously a synchronous exception there never completed the context and the request only ended by async timeout.
  • Two robustness fixes in the async processing branch of doExecute()/tryAsyncProcess(), both of which previously left the response unwritten and the UoW unfinished: a synchronous exception from processAsync() now falls back to the synchronous afterProcess(), and an exceptionally completed stage now still calls afterProcess() (which writes the error response and finishes the UoW).
  • Added ServletAsyncNoExecutorRefRaceTest (camel-servlet, embedded Undertow) and JettyAsyncDelayedRouteTest (camel-jetty, async=true&useContinuation=false) reproducing the bug with a genuinely-async route step (delay(...).asyncDelayed()), independent of any external service.
  • Added ServletAsyncErrorTest covering the error path: a route that fails after resuming on another thread, and a consumer whose processAsync() stage completes exceptionally (a route processor never does that — its exception lands on the exchange and the stage completes normally — so this uses a custom AsyncProcessor). Both expect a 500; with the tryAsyncProcess() hunk reverted, the second one gets an empty 200.

No user-facing API changes, so no upgrade guide entry; the fix is a straight cherry-pick candidate for the LTS branches.

Test plan

  • ServletAsyncNoExecutorRefRaceTest and JettyAsyncDelayedRouteTest fail before the fix (HTTP 200 with an empty body) and pass after
  • ServletAsyncErrorTest.testAsyncStageCompletesExceptionally fails (HTTP 200) with the tryAsyncProcess() change reverted, passes with it
  • camel-servlet full test suite passes
  • camel-jetty *Async*, *Continuation*, JettyRouteTest, HttpRouteTest pass
  • camel-jetty-common and camel-atmosphere-websocket compile unchanged

Fixes CAMEL-24811.

Claude Code on behalf of Croway

@github-actions

Copy link
Copy Markdown
Contributor

🌟 Thank you for your contribution to the Apache Camel project! 🌟
🤖 CI automation will test this PR automatically.

🐫 Apache Camel Committers, please review the following items:

  • First-time contributors require MANUAL approval for the GitHub Actions to run
  • You can use the command /component-test (camel-)component-name1 (camel-)component-name2.. to request a test from the test bot although they are normally detected and executed by CI.
  • You can label PRs using skip-tests and test-dependents to fine-tune the checks executed by this PR.
  • Build and test logs are available in the summary page. Only Apache Camel committers have access to the summary.

⚠️ Be careful when sharing logs. Review their contents before sharing them publicly.

@github-actions

github-actions Bot commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

🧪 CI tested the following changed modules:

  • components/camel-http-common
  • components/camel-jetty
  • components/camel-servlet

ℹ️ Dependent modules were not tested because the total number of affected modules exceeded the threshold (50). Use the test-dependents label to force testing all dependents.


🔬 Scalpel shadow comparison — Scalpel: 72 tested, 27 compile-only — current: 75 all tested

Maveniverse Scalpel detected 99 affected modules (current approach: 75).

⚠️ Modules only in Scalpel (27)
  • apache-camel
  • camel-allcomponents
  • camel-catalog
  • camel-catalog-console
  • camel-catalog-maven
  • camel-catalog-suggest
  • camel-componentdsl
  • camel-endpointdsl
  • camel-endpointdsl-support
  • camel-itest
  • camel-jbang-core
  • camel-jbang-it
  • camel-jbang-main
  • camel-jbang-plugin-edit
  • camel-jbang-plugin-generate
  • camel-jbang-plugin-kubernetes
  • camel-jbang-plugin-test
  • camel-kamelet-main
  • camel-launcher
  • camel-report-maven-plugin
  • camel-route-parser
  • camel-yaml-dsl
  • camel-yaml-dsl-deserializers
  • camel-yaml-dsl-maven-plugin
  • coverage
  • docs
  • dummy-component
Modules only in current approach (3)
  • camel-as2
  • camel-atmosphere-websocket
  • camel-quickfix

Skip-tests mode would test 72 modules (3 direct + 69 downstream), skip tests for 27 (generated code, meta-modules)

Modules Scalpel would test (72)
  • camel-a2a
  • camel-ai-observability
  • camel-clickup
  • camel-consul
  • camel-cxf-rest
  • camel-cxf-soap
  • camel-cxf-spring-rest
  • camel-cxf-spring-soap
  • camel-cxf-spring-transport
  • camel-cxf-transport
  • camel-graphql
  • camel-http
  • camel-http-common
  • camel-java-joor-dsl
  • camel-jbang-mcp
  • camel-jbang-plugin-mcp
  • camel-jbang-plugin-route-parser
  • camel-jbang-plugin-tui
  • camel-jbang-plugin-validate
  • camel-jetty
  • camel-jetty-common
  • camel-jsonpath
  • camel-kamelet
  • camel-knative-http
  • camel-kubernetes
  • camel-langchain4j-agent
  • camel-langchain4j-chat
  • camel-langchain4j-embeddings
  • camel-langchain4j-embeddingstore
  • camel-launcher-container
  • camel-lra
  • camel-mcp-server
  • camel-micrometer-observability
  • camel-micrometer-prometheus
  • camel-netty-http
  • camel-oauth
  • camel-observability-services
  • camel-openai
  • camel-openapi-validator
  • camel-opentelemetry
  • camel-opentelemetry2
  • camel-platform-http
  • camel-platform-http-jolokia
  • camel-platform-http-main
  • camel-platform-http-vertx
  • camel-rest-openapi
  • camel-rest-postman
  • camel-restdsl-openapi-plugin
  • camel-sap-netweaver
  • camel-servlet
  • camel-slack
  • camel-soap
  • camel-spring-ai-chat
  • camel-spring-rabbitmq
  • camel-state-store
  • camel-telegram
  • camel-telemetry
  • camel-telemetry-dev
  • camel-test-main-junit5
  • camel-test-main-junit6
  • camel-undertow
  • camel-undertow-spring-security
  • camel-vertx-http
  • camel-webhook
  • camel-whatsapp
  • camel-xml-io-dsl
  • camel-xml-jaxb-dsl
  • camel-xml-jaxb-dsl-test-definition
  • camel-xml-jaxb-dsl-test-spring
  • camel-yaml-dsl-validator
  • camel-yaml-dsl-validator-maven-plugin
  • camel-zookeeper
Modules with tests skipped (27)
  • apache-camel
  • camel-allcomponents
  • camel-catalog
  • camel-catalog-console
  • camel-catalog-maven
  • camel-catalog-suggest
  • camel-componentdsl
  • camel-endpointdsl
  • camel-endpointdsl-support
  • camel-itest
  • camel-jbang-core
  • camel-jbang-it
  • camel-jbang-main
  • camel-jbang-plugin-edit
  • camel-jbang-plugin-generate
  • camel-jbang-plugin-kubernetes
  • camel-jbang-plugin-test
  • camel-kamelet-main
  • camel-launcher
  • camel-report-maven-plugin
  • camel-route-parser
  • camel-yaml-dsl
  • camel-yaml-dsl-deserializers
  • camel-yaml-dsl-maven-plugin
  • coverage
  • docs
  • dummy-component

ℹ️ Shadow mode — Scalpel observes but does not affect test execution. Learn more

⚠️ Some tests are disabled on GitHub Actions (@DisabledIfSystemProperty(named = "ci.env.name")) and require manual verification:

  • components/camel-jetty: 1 test(s) disabled on GitHub Actions
Build reactor — dependencies compiled but only changed modules were tested (3 modules, 1m 29s total)

Total reactor time: 1m 29s

Module Duration Status
Camel :: Jetty 47.5s SUCCESS
Camel :: Servlet 33.2s SUCCESS
Camel :: HTTP :: Common 8.6s SUCCESS

Top 20 slowest modules:

  • Camel :: Jetty (47.5s)
  • Camel :: Servlet (33.2s)
  • Camel :: HTTP :: Common (8.6s)

⚙️ View full build and test results

@Croway
Croway force-pushed the CAMEL-24811-servlet-async-race branch from 04a6472 to eb6bd33 Compare September 18, 2026 09:13
@Croway
Croway marked this pull request as ready for review September 18, 2026 09:24
@Croway
Croway requested review from davsclaus and oscerd September 18, 2026 09:24

@oscerd oscerd left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the async completion fix carefully — it's correct, and the test pins the exact race.

The bug: doServiceAsync() (async=true, no executorRef) called AsyncContext.complete() unconditionally after doService() returned, discarding the CompletionStage from doExecute()/processAsync(). So when the route genuinely completes on another thread, the servlet container's async cycle was completed before the route wrote the response — a race.

The fix is sound:

  • doService() is void (subclasses override it), so the in-flight CompletionStage is handed over through the CamelAsyncPromise request attribute, and doServiceAsync() retrieves it and defers completion via completeOnCompletion().
  • completeOnCompletion() completes the context in both cases — immediately when there is no promise (synchronous), and via promise.whenComplete((r, e) -> context.complete()) when in-flight. whenComplete on an already-completed stage still runs, so there is no lost-completion window; the attribute is set and read on the same servlet thread, so no visibility race.
  • Exactly one complete() per request: the success path goes through completeOnCompletion and the exception path through the catch/finally, and they are mutually exclusive — no double-complete.
  • Two related correctness fixes come along: a synchronous processAsync failure now sets isAsync=false so the error response is written synchronously, and tryAsyncProcess's whenComplete now always calls afterProcess (writing the response and finishing the unit of work even when the stage completed exceptionally, which the old success-only branch skipped).

Test: ServletAsyncNoExecutorRefRaceTest#testAsyncRouteCompletesBeforeResponse uses an asyncDelayed route that resumes on the delayer's scheduler thread and asserts the response is 200 with body delayed-response — which fails on the premature-completion race and passes with the fix. JettyAsyncDelayedRouteTest covers the jetty side.

Minor (non-blocking): the new servlet test uses JUnit assertEquals; AssertJ is the project preference for new test code.

CI is green — approving.

This review was generated by an AI agent and may contain inaccuracies. Please verify all suggestions before applying.

Claude Code on behalf of oscerd

@davsclaus davsclaus left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice fix, and a thorough write-up — the analysis matches the history: the discarded CompletionStage in doServiceAsync() goes back to CAMEL-11731 (Camel 3.7), which fixed the executorRef branch but not the context.start() branch.

Verified locally on the PR branch (not just from the diff):

  • Full camel-servlet suite: 100 run, 0 failures. camel-jetty *Async*, *Continuation*, JettyRouteTest, HttpRouteTest + JettyAsyncDelayedRouteTest: 31 run, 0 failures.
  • Swapped in main's CamelServlet.java and re-ran ServletAsyncNoExecutorRefRaceTest: it fails with expected: <delayed-response> but was: <>, so the new test genuinely reproduces the race.
  • Only in-tree doService() overrides are CamelContinuationServlet (delegates to super.doService() for useContinuation=false, which is exactly why the request-attribute hand-over is needed) and CamelWebSocketServlet (never calls super, so the attribute stays null and behaviour is unchanged). Nobody overrides doServiceAsync(). Keeping doService() void for binary compatibility is the right call for the LTS backports.
  • The whenComplete(context.complete()) is chained on the whenComplete(afterProcess) stage, so the response is written before the AsyncContext completes; isAsync = false in the catch and the always-afterProcess() in tryAsyncProcess() close the leaked-UoW/unwritten-response gaps without any double afterProcess(). The finally { context.complete(); } additions are needed since onError() always throws.

One small non-blocking question inline about the visibility of the new constant.

This review was generated by an AI agent and may contain inaccuracies. Please verify all suggestions before applying.

Claude Code on behalf of davsclaus

* {@link #doService(HttpServletRequest, HttpServletResponse)} returns. The {@link AsyncContext} must not be
* completed before it.
*/
public static final String ASYNC_PROMISE_ATTRIBUTE_NAME = "CamelAsyncPromise";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: only CamelServlet itself reads and writes this attribute. Since doService() is a protected extension point, public is defensible so a subclass that fully replaces doService() can hand over its own stage — but if that is not the intent, protected would keep it off the public API surface. Either is fine with me; just flagging it since it is new public API on a class that gets backported.

@Croway
Croway force-pushed the CAMEL-24811-servlet-async-race branch from eb6bd33 to c39a0cf Compare September 18, 2026 11:50

@gnodet-bot gnodet-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewing after the new push (c39a0cf).

Previous findings addressed: The ASYNC_PROMISE_ATTRIBUTE_NAME visibility question raised inline by @davsclaus is answered: the PR keeps it as protected static final, which is the right call — it's readable by subclasses that need to hand over their own stage via doService(), but it stays off the broader public API surface.

All three correctness fixes (race in doServiceAsync, finally-block in doAsyncExecution, always-afterProcess in tryAsyncProcess) look correct. No double-afterProcess, no lost-completion window, no visibility race on the attribute.

One gap: the tryAsyncProcess().whenComplete() fix — always calling afterProcess even when the stage completes exceptionally — has no test. Both new tests only exercise the success path (route delays then returns a body). A test where the async route throws (or the processor stage completes with an exception) would verify: (a) an error response is written rather than an empty/stale body, and (b) the AsyncContext is completed exactly once. Without it, a regression to the old success-only branch would not be caught.

Suggested addition to ServletAsyncNoExecutorRefRaceTest (or a companion ServletAsyncErrorTest):

// Route that resumes on another thread and then throws
from("servlet:async-error?async=true")
    .delay(100).asyncDelayed().end()
    .process(x -> { throw new RuntimeException("boom"); });

// Test: expect 500, not a hung request or empty body
WebResponse resp = query(new GetMethodWebRequest(contextUrl + "/services/async-error"), false);
assertEquals(500, resp.getResponseCode());

This review was generated by an AI agent, Hermès on behalf of @gnodet.

try {
afterProcess(res, consumer, exchange, false);
} catch (Exception e) {
exchange.setException(e);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Missing test for the error path. This hunk moves afterProcess() outside the else branch so it runs even when the stage completes with an exception — that's the right fix. But neither new test exercises this path: both ServletAsyncNoExecutorRefRaceTest and JettyAsyncDelayedRouteTest only verify the success case (route delays, returns body). A test where processAsync() completes exceptionally would confirm that (a) an error response is actually written and (b) the AsyncContext is completed exactly once, preventing a silent regression back to the old success-only branch.

…route finishes

CamelServlet.doServiceAsync() (async=true without executorRef) completed the
AsyncContext right after doService() returned, discarding the CompletionStage
that doExecute() returns when the route continues on another thread. This
races the container's async completion against the later writeResponse(),
giving an IllegalStateException on the recycled response (Tomcat) or an
empty/stale response. camel-jetty in async mode (async=true&useContinuation=false)
hits the same path via super.doService().

doService() keeps its void signature (protected extension point, must stay
binary compatible for backports); the in-flight stage is handed over via the
CamelAsyncPromise request attribute and doServiceAsync() completes the
AsyncContext only once it is done, mirroring doAsyncExecution().

Also: complete the AsyncContext in doAsyncExecution()'s error branch (onError
always throws), and always write the response / finish the UoW when
processAsync() fails synchronously or completes exceptionally.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

@gnodet-bot gnodet-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewing after the new push (5000834).

Previous finding addressed: ServletAsyncErrorTest is exactly what was requested — testAsyncRouteThrows covers the path where an async route resumes on another thread and then throws (verifying afterProcess is called and a 500 is written), and testAsyncStageCompletesExceptionally covers the case where the processAsync() stage itself completes exceptionally (verifying the tryAsyncProcess().whenComplete() fix actually writes the error response rather than silently dropping it).

All four correctness fixes are intact and covered:

  1. doServiceAsync() raceAsyncContext.complete() now chained onto the in-flight stage via the request attribute hand-off
  2. doAsyncExecution() error branchcontext.complete() guaranteed in finally
  3. tryAsyncProcess() exceptional stageafterProcess() always called, not only on the happy path
  4. doExecute() synchronous processAsync() exceptionisAsync = false ensures afterProcess() is called on the calling thread

Approved.

This review was generated by an AI agent, Hermès on behalf of @gnodet.

@Croway
Croway merged commit 176a550 into apache:main Sep 18, 2026
5 checks passed
davsclaus pushed a commit that referenced this pull request Sep 18, 2026
…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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants