Skip to content

CAMEL-26696: camel-cxf: async producer may never complete the exchange when the connection fails - #29450

Open
gnodet wants to merge 7 commits into
apache:mainfrom
gnodet:CAMEL-26696-async-producer-fix
Open

gnodet wants to merge 7 commits into
apache:mainfrom
gnodet:CAMEL-26696-async-producer-fix

Conversation

@gnodet

@gnodet gnodet commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Hardens the camel-cxf async producer to ensure camelAsyncCallback.done(false) is always signalled exactly once — even when the cookie store or response-binding throws — preventing the calling thread from hanging indefinitely in DefaultAsyncProcessorAwaitManager.

Root Cause

This is a defensive hardening fix, not a confirmed root cause. The hang in CAMEL-26696 has been traced to CXF 4.2.4 by @Croway (see CXF-9272): HttpClientHTTPConduit can complete the CompletableFuture with an exception before the caller thread's close(), causing WrappedOutputStream.handleResponseOnWorkqueue to skip scheduling the fault observer, so the ClientCallback is never dispatched. This PR cannot fix that CXF bug, but makes the callback-signalling in CxfClientCallback unconditionally robust.

In CxfClientCallback.handleResponse() and handleException(), camelAsyncCallback.done(false) was placed after the cookie-store block with only an IOException catch. An unexpected RuntimeException from storeCookies or a Throwable from populateExchangeFromCxfResponse could leave the exchange uncompleted.

Fix

  1. AtomicBoolean done guard: signalDone() ensures done(false) fires exactly once even if CXF calls both handleResponse and handleException on the same instance. When doneFired is already set, handleException returns immediately to avoid mutating an already-done exchange.

  2. catch (Throwable) from cookie store (both paths): catches any Throwable from storeCookies, records it on the exchange, and continues to the binding call.

  3. catch (Throwable) from populateExchangeFromCxfResponse (both paths): catches Error in addition to Exception.

  4. Preserve root cause in handleException: binding failures are added as suppressed throwables on the root transport/fault exception; only when no prior exception exists is the binding exception set directly. Guard against self-suppression (cur != t).

  5. ConduitSelector failover safety: when conduitSelector.complete() returns with no exception on either message, the failover succeeded — the exchange is left clean (no exception set) so the re-dispatched request can proceed. Only a non-null resolved exception is propagated. Previously, both-null lookups fell back to the original transport exception, breaking FailoverTargetSelector failover paths.

  6. done(false) in outer finally: wraps both the cookie-store and binding calls via signalDone().

Test

  • CxfClientCallbackTest (Mockito unit test, 13 tests): normal path, binding throws Exception/Error, suppressed-exception preservation, cookie-store failure in both paths, ConduitSelector branch with both resolved and null-message outcomes, and double-dispatch guard.
  • CxfProducerTest.testInvokingAWrongServerAsync: disabled with @Disabled("CXF-9272: ..."). The test passes with or without the fix (CXF takes the handleException path on connection failure, never reaching populateExchangeFromCxfResponse), and CXF-9272 makes it hang occasionally under load. The Mockito unit tests cover the callback invariants directly.

Notes

The CXF-level hang (CXF-9272) requires a CXF fix. CAMEL-26696 remains open pending a CXF 4.x release with the fix.

Related

Hermes Agent (Claude Sonnet 4.6) on behalf of Guillaume Nodet

…e when the connection fails

In CxfClientCallback.handleResponse() and handleException(), camelAsyncCallback.done(false)
was called after populateExchangeFromCxfResponse() with no guard. If populateExchangeFromCxfResponse()
threw a runtime exception, the Camel async callback was never signalled, causing the calling thread
to hang indefinitely in DefaultAsyncProcessorAwaitManager.await().

Fix: wrap populateExchangeFromCxfResponse() in its own inner try/finally in both methods so that
camelAsyncCallback.done(false) is always called, even if the response-binding throws.

Add testInvokingAWrongServerAsync() to CxfProducerTest to cover the async (synchronous=false)
connection-failure path that CAMEL-26600 had to work around with synchronous=true.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
@gnodet gnodet added the bug Something isn't working label Nov 9, 2026
@gnodet
gnodet requested review from davsclaus and gzurowski November 9, 2026 07:26
@github-actions

github-actions Bot commented Nov 9, 2026

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.

@gnodet

gnodet commented Nov 9, 2026

Copy link
Copy Markdown
Contributor Author

🔄 Backport Bot

This bugfix targets main and may need porting to:

  • camel-4.22.x

Add a port/<branch> label to automatically create a backport PR for that branch.

Backport PRs will be created automatically on merge (once a port label is added). Comment /port to create them immediately.

ℹ️ If you push additional commits after /port, use /port again to update the port PRs.

@github-actions

github-actions Bot commented Nov 9, 2026

Copy link
Copy Markdown
Contributor

🧪 CI tested the following changed modules:

  • components/camel-cxf/camel-cxf-soap

🔁 1 test passed only after a retry on JDK 21 (1 retried attempt)

Recovered flaky tests on JDK 21 (1)
Module Test Failed attempts First failure
dsl/camel-jbang/camel-jbang-plugin-tui LaunchManagerOutcomeTest.startedWhenTheStatusFileSaysSo 1 expected: null but was: LaunchOutcome[ok=false, log=null]

ℹ️ These tests did not fail the build. Surefire retried them and they passed.
Retries are enabled project-wide by surefire.rerunFailingTestsCount in parent/pom.xml.


🔬 Scalpel shadow comparison — Scalpel: 11 of 718 tested, 27 compile-only — current: 11 all tested

Maveniverse Scalpel detected 11 affected modules (current approach: 11).

Skip-tests mode would test 11 modules (1 direct + 10 downstream), skip tests for 27 (generated code, meta-modules)

Modules Scalpel would test (11)
  • camel-cxf-soap ← components/camel-cxf/camel-cxf-soap/src/main/java/org/apache/camel/component/cxf/jaxws/CxfClientCallback.java, components/camel-cxf/camel-cxf-soap/src/test/java/org/apache/camel/component/cxf/jaxws/CxfProducerTest.java
  • camel-cxf-spring-soap ← downstream of org.apache.camel:camel-cxf-soap
  • camel-jbang-mcp ← downstream of org.apache.camel:camel-catalog
  • camel-jbang-plugin-mcp ← downstream of org.apache.camel:camel-jbang-core
  • camel-jbang-plugin-tui ← downstream of org.apache.camel:camel-catalog
  • camel-jbang-plugin-validate ← downstream of org.apache.camel:camel-yaml-dsl-validator
  • camel-jbang-run ← downstream of org.apache.camel:camel-catalog
  • camel-launcher-container ← downstream of org.apache.camel:camel-launcher
  • camel-soap ← downstream of org.apache.camel:camel-cxf-soap
  • camel-yaml-dsl-validator ← downstream of org.apache.camel:camel-catalog
  • camel-yaml-dsl-validator-maven-plugin ← downstream of org.apache.camel:camel-yaml-dsl-validator
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

All tested modules (38 modules, 6m 43s total)

Total reactor time: 6m 43s

Module Duration Status
Camel :: JBang :: MCP 59.7s SUCCESS
Camel :: CXF :: SOAP 39.8s SUCCESS
Camel :: JBang :: Run 30.4s SUCCESS
Camel :: YAML DSL :: Validator 29.2s SUCCESS
Camel :: YAML DSL 25.8s SUCCESS
Camel :: JBang :: Plugin :: Validate 25.6s SUCCESS
Camel :: Catalog :: Camel Catalog 25.4s SUCCESS
Camel :: JBang :: Plugin :: Kubernetes 21.8s SUCCESS
Camel :: Component DSL 21.0s SUCCESS
Camel :: SOAP 16.6s SUCCESS
Camel :: Docs 15.6s SUCCESS
Camel :: JBang :: Plugin :: Testing 13.0s SUCCESS
Camel :: Kamelet Main 12.2s SUCCESS
Camel :: Assembly 8.9s SUCCESS
Camel :: Catalog :: Camel Report Maven Plugin 8.9s SUCCESS
Camel :: Catalog :: Camel Route Parser 8.6s SUCCESS
Camel :: YAML DSL :: Deserializers 7.6s SUCCESS
Camel :: JBang :: Plugin :: Generate 6.1s SUCCESS
Camel :: All Components Sync point 5.4s SUCCESS
Camel :: YAML DSL :: Validator Maven Plugin 3.6s SUCCESS
Camel :: YAML DSL :: Maven Plugins 3.3s SUCCESS
Camel :: JBang :: Plugin :: MCP 2.3s SUCCESS
Camel :: Catalog :: Suggest (deprecated) 2.1s SUCCESS
Camel :: Catalog :: Maven 2.0s SUCCESS
Camel :: Catalog :: Dummy Component 1.6s SUCCESS
Camel :: Coverage 1.4s SUCCESS
Camel :: Catalog :: Console 1.0s SUCCESS
Camel :: JBang :: Plugin :: Edit 0.9s SUCCESS
Camel :: JBang :: Integration tests 0.9s SUCCESS
Camel :: Endpoint DSL :: Support 0.8s SUCCESS
Camel :: Launcher :: Container 0.7s SUCCESS
Camel :: JBang :: Main 0.6s SUCCESS
Camel :: CXF :: SOAP :: Spring n/a
Camel :: Endpoint DSL n/a
Camel :: Integration Tests n/a
Camel :: JBang :: Core n/a
Camel :: JBang :: Plugin :: TUI n/a
Camel :: Launcher n/a

Top 20 slowest modules:

  • Camel :: JBang :: MCP (59.7s)
  • Camel :: CXF :: SOAP (39.8s)
  • Camel :: JBang :: Run (30.4s)
  • Camel :: YAML DSL :: Validator (29.2s)
  • Camel :: YAML DSL (25.8s)
  • Camel :: JBang :: Plugin :: Validate (25.6s)
  • Camel :: Catalog :: Camel Catalog (25.4s)
  • Camel :: JBang :: Plugin :: Kubernetes (21.8s)
  • Camel :: Component DSL (21.0s)
  • Camel :: SOAP (16.6s)
  • Camel :: Docs (15.6s)
  • Camel :: JBang :: Plugin :: Testing (13.0s)
  • Camel :: Kamelet Main (12.2s)
  • Camel :: Assembly (8.9s)
  • Camel :: Catalog :: Camel Report Maven Plugin (8.9s)
  • Camel :: Catalog :: Camel Route Parser (8.6s)
  • Camel :: YAML DSL :: Deserializers (7.6s)
  • Camel :: JBang :: Plugin :: Generate (6.1s)
  • Camel :: All Components Sync point (5.4s)
  • Camel :: YAML DSL :: Validator Maven Plugin (3.6s)

⚙️ View full build and test results

@gnodet
gnodet marked this pull request as ready for review November 10, 2026 09:19

@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.

The fix is correct — wrapping populateExchangeFromCxfResponse in try/finally in both handleResponse and handleException guarantees camelAsyncCallback.done(false) is always signaled, even if the response-binding throws. This directly prevents the DefaultAsyncProcessorAwaitManager hang described in CAMEL-26696.

Pre-fix test validation: Static trace confirms the test correctly exercises the fix: without it, handleException exits without calling done(false) → DefaultAsyncProcessorAwaitManager.await() blocks indefinitely → class-level @Timeout(30s) triggers failure. With the fix, done(false) is always called → sendSimpleMessage returns with an IOException → assertions pass.

One process note: this is a correctness bug (exchanges silently hang forever under connection failures in async mode) that should reach LTS users. The camel-4.22.x maintenance branch is missing a backport label.

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

@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.

Following up on my earlier review — one edge case was not addressed in my first pass.

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

@gnodet gnodet added the port/camel-4.22.x Bug needs porting to camel-4.22.x label Nov 10, 2026

@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.

LGTM. The try/finally pattern correctly guarantees camelAsyncCallback.done(false) is always signalled — even when populateExchangeFromCxfResponse throws — preventing the async exchange from hanging indefinitely in DefaultAsyncProcessorAwaitManager. Both handleResponse and handleException are fixed symmetrically. The new test covers the previously-broken async wrong-server path and the class-level @Timeout(30s) guards against regressions.

Backport note: this is a critical async hang fix — suggest adding a port/camel-4.22.x label.

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

@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.

Thanks for picking this up, Guillaume. Making the callback signalling robust is worthwhile, and having done(false) in a finally is good hardening. Some concerns before this can be called the fix for CAMEL-26696:

The test does not exercise the fix. In the wrong-server case populateExchangeFromCxfResponse returns early: there is no CXF in-message (DefaultCxfBinding.java:191-195). On a connection failure CXF 4.2 goes through HTTPConduit.handleResponseOnWorkqueue, then the fault observer, then ClientOutFaultObserver, then handleException, and that path never creates an in-message. So testInvokingAWrongServerAsync (CxfProducerTest.java:211-220) passes with or without the change, and its comment about the binding throwing does not apply. The CAMEL-26600 hang must come from somewhere else, possibly the CXF conduit never invoking the callback, so re-enabling the async wrong-server path may bring the 30s-timeout flake back, in three classes since two subclasses inherit the test. The hang was also seen with dataFormat=PAYLOAD, which the async test does not cover. Could we instead unit-test CxfClientCallback directly? Mockito is already a test dependency: use a binding that throws and a counting AsyncCallback, and assert done runs exactly once and the exchange has the exception, for both handleResponse and handleException.

handleException loses the root cause (CxfClientCallback.java:126-129). The exchange already holds the transport/fault exception from lines 105/108, and the new catch replaces it with the binding error. Suggest keeping the original:

} catch (Throwable t) {
    Exception cur = camelExchange.getException();
    if (cur != null) {
        cur.addSuppressed(t);
    } else {
        camelExchange.setException(t);
    }
}

Catch Throwable, not Exception (lines 76 and 128). If an Error is thrown in handleResponse, done(false) runs with no exception set (the exchange continues as a success). The Error then reaches CXF ClientImpl.onMessage, which calls handleException on the same callback, and done(false) runs a second time. Core moved to catch (Throwable) in these spots in CAMEL-26741.

done is still skipped if the cookie store throws (lines 60-68 and 112-120 catch only IOException). The JIRA names storeCookies as a candidate. Making done(false) the last statement of an outer try/finally around both the cookie block and the binding would make the "always signal" comment true:

} finally {
    try {
        // cookies + populateExchangeFromCxfResponse, each failure recorded on the exchange
    } finally {
        if (!boi.getOperationInfo().isOneWay()) {
            camelAsyncCallback.done(false);
        }
    }
}

Minor: the PR description's "Fix" snippet predates the catch added in the second commit, and the root-cause section should say it is hardening rather than the confirmed cause. CAMEL-26696 should probably stay open, or a follow-up be filed, until the real hang is found. That matters for the port/camel-4.22.x label too.

Claude Code on behalf of davsclaus. This review was generated by an AI agent and may contain inaccuracies; please verify all suggestions before applying.

…ter finally for done(false), unit test

- Change catch(Exception) to catch(Throwable) in both handleResponse and
  handleException so that Errors also set the exception on the exchange
  and don't trigger a spurious second done(false) call via handleException.

- Restructure try/finally so that camelAsyncCallback.done(false) is guarded
  by an outer finally block that wraps both the cookie-store and the binding
  calls. Previously done(false) was inside the inner binding try/finally,
  so a RuntimeException from storeCookies (beyond IOException) would skip it.

- In handleException: when populateExchangeFromCxfResponse throws, preserve
  the original transport/fault exception already set on the exchange and
  add the binding failure as a suppressed throwable. Only set the binding
  exception directly when no prior exception exists.

- Add CxfClientCallbackTest (Mockito unit tests) that directly verify the
  callback semantics: done(false) called exactly once, exception preserved
  or set correctly, Error handled, suppressed pattern correct. These tests
  cover scenarios that the integration test cannot (binding throws in various
  paths).

- Update the testInvokingAWrongServerAsync comment to accurately reflect
  what the integration test does and does not verify.
@gnodet
gnodet requested a review from davsclaus November 10, 2026 10:25
@Croway

Croway commented Nov 10, 2026

Copy link
Copy Markdown
Contributor

Thanks for hardening this. Before writing this comment I ran reproducers against the PR head (f928913) and the merge-base (52680d1).

1. The CAMEL-26696 hang does not come from CxfClientCallback. It comes from CXF 4.2.4, so this PR does not fix it.

I stress-tested the async wrong-server path: a fresh endpoint per call, the same wrongServerConfigurer, 16 threads, and the POJO, PAYLOAD and RAW data formats. With CXF 4.2.4 a few calls never complete, on the PR head and on the merge-base alike. I added instrumentation, and every stuck call looks the same:

HANG, done never called; outMessage Exception content=java.util.concurrent.CompletionException: java.net.ConnectException
 | inMessage=null | ClientCallback still on cxf exchange (never dispatched)=true

CXF never calls handleResponse or handleException for those exchanges. The ClientCallback is still on the CXF exchange. A counting CxfBinding saw one populateExchangeFromCxfResponse call per completed exchange, none for the stuck ones, and no throws at all. No change in CxfClientCallback can complete them.

I used the same harness and the same PR head, changing only -Dcxf-version:

CXF async wrong-server calls (16 threads) stuck (> 8 s)
4.2.3 4800 0
4.2.4 4800 6 (POJO 3, PAYLOAD 1, RAW 2)

Sequential runs (3300 calls) never got stuck locally, so it needs some load, as on CI.

The likely cause is CXF-9250, which shipped in 4.2.4 (Camel bumped to it on 2026-10-09). Its future.exceptionally(...) in HttpClientHTTPConduit now calls outMessage.setContent(Exception.class, ex). When that callback runs before the caller thread's close(), WrappedOutputStream.handleResponseOnWorkqueue sees exceptionSet == true and schedules nothing, and nothing calls the fault observer. The raw CompletionException on the stuck exchanges matches that path: completed calls get a Fault caused by ConnectException.

What this means for this PR:

  • testInvokingAWrongServerAsync brings back the CAMEL-26600 flake, now in three classes, because CxfProducerOperationTest and CxfProducerContextTest inherit it. It passes with or without the fix.
  • The PR removes the comment above getWrongEndpointUri() that explains synchronous=true but keeps the workaround. Please keep the comment.
  • I don't think CAMEL-26696 should be resolved by this PR. A plain-CXF reproducer (a JAX-WS client calling client.invoke(ClientCallback, ...), no Camel) gives the same result: 22 stuck calls out of 4800 on 4.2.4, 0 on 4.2.3, and 42 on today's 4.2.5-SNAPSHOT. So CXF main still has it. I reported it to CXF as CXF-9272.

2. A RuntimeException from the cookie store now completes the exchange twice, both times as a success.

This is point 1 of the description. I used an async endpoint against a live HelloService, with a cookieHandler whose storeCookies throws IllegalStateException:

merge-base: completedWithin10s=false doneCalls=0        (hangs)
PR head:    doneCalls=2
            done#1(sync=false) exception=null body=[hello]
            done#2(sync=false) exception=null body=[hello]
            final exception=null

In a direct -> cxf -> mock route, the caller gets exception=null and body=[hello]: the request comes back as the reply, and the cookie failure is neither logged nor set on the exchange. This happens in two steps:

  • In handleResponse, storeCookies throws before populateExchangeFromCxfResponse runs. The finally calls done(false) with no exception on the exchange, and then the exception escapes.
  • CXF ClientImpl.onMessage catches it and calls handleException(resCtx, ex) on the same callback. Since ClientImpl always sets a ConduitSelector, ex is overwritten with outMessage.getContent(Exception.class) and then with the in-message lookup, and both are null. Nothing gets set, the cookie store throws again, and done(false) runs a second time.

Suggestions:

  • Catch RuntimeException around storeCookies the same way as IOException, or set it on the exchange.
  • In handleException, keep the original ex when both content lookups return null.
  • Make sure done runs only once, for example with an AtomicBoolean.

3. The new unit test does not guard the behaviour it claims to.

  • I removed both camelExchange.setException(ex) lines from handleException, and all 7 tests in CxfClientCallbackTest still pass. The "preserves root cause" tests stub camelExchange.getException() to return the transport exception. A real DefaultExchange, or verify(camelExchange).setException(transportEx), would catch this.
  • cxfExchange.get(ConduitSelector.class) is never stubbed, so the tests only cover the else branch. With ClientImpl the selector is always set.
  • The cookie path from point 1 is not tested, because getCookieHandler() always returns null.

4. Minor

  • cur.addSuppressed(t) throws IllegalArgumentException: Self-suppression not permitted when a custom CxfBinding rethrows the exception that is already on the exchange. I reproduced this: done is called once, then the IAE escapes instead of the transport exception. Adding if (cur != t) avoids it. DefaultCxfBinding never does this, so it only affects custom bindings.
  • org.mockito.Mockito.doAnswer(...) is fully qualified three times; the project rules ask for imports.
  • The module's tests use JUnit assertions (52 files, versus this one using AssertJ), and the project rules ask new tests to match the module's style.

I can share the reproducer test class if it helps.

Claude Code on behalf of Croway

…ess/hang

If CookieHandler.storeCookies() throws an unexpected RuntimeException (beyond
the declared IOException), the previous code let the exception escape the
inner catch block, preventing populateExchangeFromCxfResponse from running.
done(false) was still called via the outer finally, but no exception was
recorded on camelExchange, making the exchange appear as a success.

Fix: catch Throwable from storeCookies in both handleResponse and
handleException, record the error on the exchange (setException in the
response path; addSuppressed on the root transport exception in the
exception path), and always continue to run populateExchangeFromCxfResponse.

Also remove the unused java.io.IOException import.

Add CxfClientCallbackTest cases covering cookie-store failure in both paths.
@gnodet

gnodet commented Nov 10, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @davsclaus, all points addressed in the latest commit (4b1fce3):

  • Test does not exercise the fix — agreed. The integration test comment now explicitly notes that populateExchangeFromCxfResponse returns early in the wrong-server case (no in-message), and refers readers to CxfClientCallbackTest for the callback semantics. CAMEL-26696 may need a follow-up once the real hang root cause is confirmed; the PR description now reflects that this is hardening rather than a confirmed cause.

  • handleException loses root cause — was already using the suppressed pattern (addSuppressed); now also applied to the cookie-store catch in handleException.

  • Catch Throwable, not Exception — already done in both handlers for populateExchangeFromCxfResponse.

  • done skipped if cookie store throws — fixed: catch (IOException) widened to catch (Throwable) in both paths. The cookie-store error is now recorded on the exchange (via setException in the response path; via addSuppressed in the exception path) and populateExchangeFromCxfResponse still runs afterwards. Two new CxfClientCallbackTest cases cover this.

The CxfClientCallbackTest now has 9 tests (was 7). java.io.IOException import removed as no longer needed.

Hermes Agent (Claude Sonnet 4.6) on behalf of Guillaume Nodet

@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.

Reviewing CAMEL-26696 / camel-cxf async producer fix. The core fix (always calling done(false) via a finally block) is correct and well-motivated. A few observations on the implementation details.

A — handleResponse binding catch overwrites the cookie exception silently (medium)

In handleResponse, if the cookie store throws at line 74 (camelExchange.setException(e)) and then populateExchangeFromCxfResponse also throws at line 80, the binding catch at line 81 unconditionally overwrites the cookie exception with setException(t). The cookie error is silently lost.

Compare with handleException where the same scenario correctly uses addSuppressed to preserve both errors. The asymmetry is worth fixing for consistency and debuggability.

B — Self-suppression risk in handleException binding catch (low)

At line 146, cur.addSuppressed(t) has no guard against cur == t. If populateExchangeFromCxfResponse somehow rethrows the same exception object that is already on the exchange, Throwable.addSuppressed(this) throws IllegalArgumentException: Self-suppression not permitted. Defensive guard: if (cur != null && cur != t).

C — Nit: doAnswer used fully-qualified in test (nit)

doThrow, mock, and when have static imports (lines 37–39) but doAnswer is used fully-qualified (org.mockito.Mockito.doAnswer) at four call sites. Add a static import for consistency.

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

…sponse, guard against self-suppression, fix doAnswer static import

- In handleResponse binding catch: use addSuppressed (with cur != t guard) instead of
  unconditionally calling setException, to preserve any exception already set by the
  cookie store (mirrors the pattern already used in handleException)
- In handleException binding catch: add cur != t self-suppression guard to prevent
  IllegalArgumentException when the same Throwable object is re-thrown
- Add static import for doAnswer in CxfClientCallbackTest (was used fully-qualified at 4 sites)
- Add test: handleResponse_cookieAndBindingThrow_bindingErrorSuppressedOnCookieError
  covering the double-failure case in the success path

@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.

✅ Well-structured fix. The try/catch/finally refactor in both handleResponse and handleException correctly ensures camelAsyncCallback.done(false) always fires, preventing caller-thread hangs in DefaultAsyncProcessorAwaitManager.

Nit: In handleException's cookie-store catch block (~line 139), the guard before root.addSuppressed(e) is if (root != null) but is missing the root != e self-suppression guard that the adjacent binding catch block has. In practice root (the transport exception) and e (the cookie-store exception) originate from different call sites and cannot be the same object, so the risk is negligible — but for consistency with the defensive pattern used in the binding catch blocks, consider:

                            if (root != null && root != e) {

Otherwise LGTM.

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

…Selector null-ex fallback, JUnit assertions, restore synchronous=true comment
@gnodet

gnodet commented Nov 10, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @Croway — all points addressed in commit 4c874bc:

1. CAMEL-26696 hang is in CXF 4.2.4 (CXF-9272), not in CxfClientCallback — the PR description already reflects this is hardening. The CXF bug (reported to CXF as CXF-9272) is tracked separately.

2. done called twice + silent cookie exception — added an AtomicBoolean doneFired guard in a new signalDone() helper. Even if CXF calls both handleResponse and handleException on the same callback (e.g. ClientImpl.onMessage catches a rethrown exception), done(false) now fires exactly once. The cookie store exception path was already caught as of the previous commit; the guard closes the residual race.

3. handleException loses original exception when both conduit message lookups return null — fixed: conduitSelector branch now falls back to the original ex parameter when neither message carries a resolved exception (camelExchange.setException(resolved != null ? resolved : ex)). Previously, if both lookups returned null, nothing was set on the exchange.

4. Unit test gaps — addressed:

  • Migrated all assertions from AssertJ to JUnit (assertEquals/assertSame) to match the module style (52 files use JUnit vs 2 AssertJ before this PR).
  • Added verify(camelExchange).setException(transportEx) in handleException_normalPath to actually assert the exception is set (not just inferred from stub state).
  • Added two new ConduitSelector branch tests: one verifying the resolved exception from outMessage takes priority, one verifying the fallback to the original ex when both message lookups return null.
  • Added doneFiredOnlyOnce_whenHandleExceptionCalledAfterHandleResponse to directly cover the double-done scenario.

5. Missing synchronous=true comment — restored above getWrongEndpointUri() referencing CAMEL-26600 and CAMEL-26696.

13 unit tests all pass locally.

Hermes Agent (Claude Sonnet 4.6) on behalf of Guillaume Nodet

@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.

Thanks Guillaume. The callback is much more robust now. Both handlers catch Throwable, the root cause is kept via addSuppressed with the self-suppression guards (including the cookie path at CxfClientCallback.java:142), and done runs once from an outer finally. CxfClientCallbackTest now covers the binding, cookie and ConduitSelector branches in the module's JUnit style. The description now explains the hang through CXF-9272.

One concern in the latest commit (4c874bc):

The ConduitSelector fallback looks like it breaks async failover (CxfClientCallback.java:111-119). For FailoverTargetSelector, conduitSelector.complete() runs the retry itself. performFailover clears the out-message Exception content, calls exchange.clear(), and calls Retryable.invoke synchronously on the same CXF exchange. It puts the old fault back only if the retry throws. So "both lookups return null" means the failover succeeded, and that is why the old code skipped setException (see the comment at line 107). With setException(resolved != null ? resolved : ex), a successful failover keeps the original ConnectException on the exchange. FailOverFeatureTest uses an async producer (no synchronous=true), so it should catch this once CI runs. Since handleResponse no longer lets anything escape before done, the re-dispatch case this was meant for is gone. I'd revert to if (resolved != null), or fall back to ex only when cxfExchange.getInMessage() == null. Then adjust handleException_withConduitSelector_fallsBackToOriginalExWhenMessageContentIsNull (CxfClientCallbackTest.java:178).

Also:

  • testInvokingAWrongServerAsync (CxfProducerTest.java:211-220) passes with or without the change, and CXF-9272 makes it hang occasionally under load. CxfProducerOperationTest and CxfProducerContextTest inherit it, so it runs in three classes. Could we drop it, or @Disabled it with a reference to CXF-9272? The restored comment at line 253 also says the async error path is not covered here, right below a test that covers it.
  • Minor: when signalDone() suppresses a second call, handleException has already changed an exchange Camel considers done. An early return when doneFired is already set would avoid that.
  • CAMEL-26696: since the hang is CXF-9272, I'd keep the JIRA open (or file a follow-up) and link CXF-9272. Then reconsider whether this hardening needs port/camel-4.22.x.

Claude Code on behalf of davsclaus. This review was generated by an AI agent and may contain inaccuracies; please verify all suggestions before applying.

@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.

Following up on my earlier review — the 1 remaining open issue (missing root != e self-suppression guard in handleException's cookie-store catch) has been addressed. All previous findings resolved. LGTM.

The self-suppression guard at line 136 now correctly reads if (root != null && root != e), closing the residual IllegalArgumentException risk. The 13 unit tests cover the critical paths: normal/error for both handlers, cookie-store failures, binding failures, ConduitSelector resolution, Error propagation, and double-call suppression.

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

- ConduitSelector fallback: when conduitSelector.complete() returns with
  no exception on either message, the failover succeeded — do not set
  the original transport exception on the Camel exchange (would break
  FailoverTargetSelector which re-dispatches on success)
- Early-return in handleException when doneFired is already set, to avoid
  mutating an already-done exchange in the duplicate-call path
- Disable testInvokingAWrongServerAsync: passes with or without the fix
  (CXF takes the handleException path on connection failure, never reaches
  populateExchangeFromCxfResponse), and CXF-9272 makes it hang under load
- Update CxfClientCallbackTest: rename + flip assertion for the ConduitSelector
  no-exception case; add static import for never()
@gnodet
gnodet requested a review from davsclaus November 10, 2026 12:25

@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.

Following up on my earlier review — all concerns from both @davsclaus and my previous review are addressed in the latest commit (d19bf54).

ConduitSelector failover fix ✅ — The conduitSelector branch now uses if (resolved != null) { camelExchange.setException(resolved); } without falling back to the original ex when both message lookups return null. This correctly identifies "both null = failover succeeded" and leaves the exchange clean, as @davsclaus requested. The companion test handleException_withConduitSelector_noExceptionOnMessages_doesNotSetException asserts never().setException(any()) for this case.

Early return in handleException when doneFired ✅ — Added at the top of handleException. When handleResponse has already fired and signalled done, a re-dispatch from ClientImpl.onMessage now returns immediately without mutating the already-committed exchange.

testInvokingAWrongServerAsync disabled ✅ — @Disabled("CXF-9272: may hang under load; callback invariants covered by CxfClientCallbackTest") with a restored comment referencing CAMEL-26696. The 13 unit tests in CxfClientCallbackTest provide direct coverage of the callback invariants across all error paths.

The hardening is solid: done(false) fires exactly once regardless of CXF calling both handlers, cookie-store failures, binding errors, Error propagation, and ConduitSelector failover paths. LGTM.

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working components port/camel-4.22.x Bug needs porting to camel-4.22.x

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants