Skip to content

CAMEL-26112: camel-milo - close a failed connection without waiting for its session to close - #28511

Merged
davsclaus merged 2 commits into
apache:mainfrom
etzel-mes:fix/CAMEL-26112-milo-disconnect-outside-lock
Oct 27, 2026
Merged

davsclaus merged 2 commits into
apache:mainfrom
etzel-mes:fix/CAMEL-26112-milo-disconnect-outside-lock

Conversation

@etzel-mes

@etzel-mes etzel-mes commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Description

Follow-up of CAMEL-25912 (#28371). When a consumer-side failure tears the connection down, handleConnectionFailure() disposed it with OpcUaClient.disconnect(), which sends CloseSession and waits for it, under the manager lock. On a silent network drop the CloseSession is not answered, so the wait lasted 6–7 s on a real server (until the channel's next reconnect attempt failed), and the connection was rebuilt only after it.

Measuring it corrected the premise of the issue: no other thread waited on the manager lock for it. The caller is the thread that starts or stops a consumer route, and other route starts and stops in the same CamelContext wait on the context's own route lock, so moving a synchronous disconnect out of the manager lock would not have shortened anything. What helps is not waiting at all.

What changes:

  • A connection torn down because it failed (handleConnectionFailure() and the "Failed to set subscriptions" path of the reconnect) is closed with Milo's OpcUaClient.disconnectAsync() without waiting; a failed disconnect is still logged as Failed to disconnect client. In Milo 1.1.8 disconnect() is disconnectAsync().get(), so this is the same operation without the wait; it runs on Milo's threads, not on the MiloClient executor or the MiloClientReconnect thread.
  • The connection is marked closed before either disconnect, as before, so the session it closes is not reported as a loss.
  • Unchanged: disposing the manager (component stop) still waits for the clean disconnect; options, defaults and log texts are unchanged, so no doc change.

Tests: new CommunicationLossTest.failedConnectionIsRebuiltWithoutWaitingForItsSessionToClose (embedded server behind the TCP relay, requestTimeout=1000): after silence(), a registration times out and the registering thread must never be inside OpcUaClient.disconnect; the connection is then rebuilt and both consumers end on the good value. It fails on the unmodified code (connect to failure 6.5 s against 1.3 s) and with only handleConnectionFailure() reverted. Module -Psourcecheck verify with -Dsurefire.rerunFailingTestsCount=0: 126 tests, 0 failures, 1 skipped (as upstream).

Measured on a real server (opc-plc 2.15.4, server and client with --cpus=1, silent drop with iptables, one CamelContext with consumers, a 50-node consumer route started into the drop, another route stopped and started every 2 s):

scenario before after
route start 1 s into a 45 s drop: dispose under the lock / route start / other route op waiting (2 runs) 6.05–6.06 s / 19.3–19.6 s / 15.2–15.5 s ≤ 30 ms / 13.4–13.7 s / 9.4–9.7 s
route start after the session is declared lost, 75 s drop (2 runs) 7.0 s / 12.5–12.8 s / 8.4–8.5 s < 25 ms / 5.6–5.8 s / 1.6–1.8 s
16–18 s blip, link back while the old CloseSession is pending: restore to first good value (3 runs) 1.5 / 1.5 / 4.4 s 1.1 / 1.6 / 1.6 s

The rest of a route start into a drop is its CreateMonitoredItems call waiting until the keep-alive declares the session lost, as designed. Reconnect attempts keep the same 15 s cadence; it now starts 6–7 s earlier. In the blip runs both versions leave the old session on the server until its session timeout: the late CloseSession either stays in the dead TCP connection or is rejected with BadSecureChannelIdInvalid on the new channel, and it never disturbed the new session. Healthy runs: no teardown, no change.

A connection that performConnect() completes after the manager was disposed is now closed too (pre-existing leak, found while testing). CAMEL-26113 and CAMEL-26114 stay separate.

Fixes CAMEL-26112.

@davsclaus @oscerd

Target

  • I checked that the commit is targeting the correct branch (Camel 4 uses the main branch)

Tracking

  • If this is a large change, bug fix, or code improvement, I checked there is a JIRA issue filed for the change (usually before you start working on it).

Apache Camel coding standards and style

  • I checked that each commit in the pull request has a meaningful subject line and body.
  • I have run mvn clean install -DskipTests locally from root folder and I have committed all auto-generated changes.

AI-assisted contributions

  • If this PR includes AI-generated code, commits have proper co-authorship attribution (e.g., Co-authored-by trailers) and the PR description identifies the AI tool used.

Claude Code on behalf of etzel-mes

@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. Clean, well-scoped fix that eliminates a 6–7 s stall on network failure by switching two call sites from synchronous disconnect() to fire-and-forget disconnectAsync(). Thread-safety is maintained by the existing locking protocol, and the new integration test correctly validates the fix.

The refactoring of close() as a shared guard method with dispose()/disposeFailed() as the two disconnect strategies is clean and self-documenting. The Javadoc on disposeFailed() correctly explains why the server won't answer CloseSession on a dead link. The test validates both the non-blocking property (the registering thread never enters OpcUaClient.disconnect()) and functional correctness (connection rebuilt, both consumers receive the current value).

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

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

Nice, well-targeted liveness fix. The problem is clear: on the failure paths (handleConnectionFailure, and the catch in performAndEvalConnect) the old code called dispose(), whose synchronous client.disconnect() sends a CloseSession and waits — but on a link that went silent the server never answers, so the call blocks until it times out (or a reconnect attempt fails) while holding lock on the consumer start/stop or reconnect thread, stalling the rebuild. Splitting the "mark closed once" guard into close() and adding disposeFailed() that uses disconnectAsync().whenComplete(...log...) lets a failed connection be torn down without blocking, while the clean path (dispose()) still waits as before. The two call sites are switched to disposeFailed() correctly.

The test is the strong part: failedConnectionIsRebuiltWithoutWaitingForItsSessionToClose silences the relay, triggers the teardown from a registering thread, and inspects that thread's stack to assert it never sits in OpcUaClient.disconnect — i.e. it directly proves the non-blocking path is taken — then verifies the connection is rebuilt (a new client instance) and both consumers resume. @Timeout(60s), Awaitility, no Thread.sleep, and it stays in the file's existing JUnit-assertion style.

Non-blocking:

  • Asserting "didn't block" by polling the thread's stack for OpcUaClient.disconnect is a touch fragile (method-name + timing dependent), though the surrounding "rebuilt without waiting" assertion and the hard timeout make it robust enough in practice.
  • disposeFailed() is fire-and-forget; fine for a failed link being replaced, just noting the async disconnect isn't awaited anywhere.

CI hasn't run here (looks like a fork PR needing a maintainer to trigger it) — worth kicking off before merge.

Claude Code on behalf of oscerd

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

@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 the careful measurements. Switching the failure paths to disconnectAsync() is the right call. I checked the Milo 1.1.8 jar: disconnect() really is disconnectAsync().get(), and closeSession() only fires an FSM event, so the caller no longer waits. Marking the connection closed before either disconnect, and keeping the waiting dispose() for component stop, both look good.

Two things I'd like to see in this PR:

  1. SubscriptionManager.java:1357-1360: the "disposed during connect" case you list under "Not done". When the manager is disposed while performConnect() is running, the new connection's client is never disconnected. reconnectJob.cancel(false) doesn't stop a connect that is already running, and MiloClientConnection.close() shuts the executors down gracefully. So a live client with an open session and keep-alive outlives the component. It's a one-liner a few lines above the disposeFailed() you changed. Could we add connected.dispose(); (or disposeFailed()) before that return, rather than leave it for a follow-up?

  2. SubscriptionManager.java:229-234: onTransferFailed calls handleConnectionFailure(...) without checking this.closed. onSessionInactive does check it, and recoverSubscription/recreateSubscription check isCurrent. With this PR, the failed client keeps running after the rebuild until its CloseSession finishes. In Milo 1.1.8 a CloseSession that arrives while the session is Transferring is shelved until the transfer is done (SessionFsmFactory, Transferring state). If that transfer fails, the old subscription calls onTransferFailed, and handleConnectionFailure then tears down whatever connection is current, which may be the replacement. A guard such as if (!this.closed) there (or isCurrent(subscription) under lock) would close this off.

Nit: in disposeFailed() (SubscriptionManager.java:656-660), the whenComplete error branch can't run. Milo 1.1.8 ends disconnectAsync() with .exceptionally(t -> this), so the future always completes normally. Fine to keep as a guard against future Milo versions, but a short comment (or dropping it) would avoid suggesting the failure is logged.

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

@etzel-mes

Copy link
Copy Markdown
Contributor Author

Thanks @davsclaus, all three are in 0e55717.

  1. Disposed during connect: that branch now calls connected.disposeFailed(). By then dispose() has returned and nothing waits on the reconnect thread, so the non-waiting close fits; waiting would hold lock until CloseSession is answered. CommunicationLossTest#connectionCompletedAfterDisposeIsClosed disposes the manager while the connect is in flight (the relay listens but accepts only after dispose()) and checks that the relayed connection gets closed; it fails without the fix.
  2. onTransferFailed now tears down only when isCurrent(subscription) under lock, the check recoverSubscription already uses. I took it over !closed because dispose() closes the connection outside the lock, so closed can still be false after connected = null. The other callbacks were already covered: session active/inactive check closed, and the status and watchdog paths reach the teardown only through isCurrent. No test for this one: it needs the old client in Transferring while the server rejects TransferSubscriptions, which the test server cannot do deterministically.
  3. Dropped the error branch, with a short comment that the future completes normally.

I also moved the disposed-during-connect case out of "Not done" in the description.

Claude Code on behalf of etzel-mes

@github-actions

github-actions Bot commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

🧪 CI tested the following changed modules:

  • components/camel-milo

🔁 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 MonitorStartupTest.browserOnlyServesUntilTheServerStops 1 Connection refused

ℹ️ 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: 8 of 708 tested, 27 compile-only — current: 8 all tested

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

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

Modules Scalpel would test (8)
  • 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-launcher-container ← downstream of org.apache.camel:camel-launcher
  • camel-milo ← components/camel-milo/src/main/java/org/apache/camel/component/milo/client/internal/SubscriptionManager.java, components/camel-milo/src/test/java/org/apache/camel/component/milo/CommunicationLossTest.java, components/camel-milo/src/test/java/org/apache/camel/component/milo/TcpRelay.java
  • 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 (35 modules, 4m 31s total)

Total reactor time: 4m 31s

Module Duration Status
Camel :: JBang :: Plugin :: TUI 57.0s SUCCESS
Camel :: Launcher 27.0s SUCCESS
Camel :: JBang :: Plugin :: Validate 24.7s SUCCESS
Camel :: JBang :: MCP 19.5s SUCCESS
Camel :: YAML DSL :: Validator 18.9s SUCCESS
Camel :: YAML DSL 18.7s SUCCESS
Camel :: Catalog :: Camel Catalog 15.3s SUCCESS
Camel :: Component DSL 13.4s SUCCESS
Camel :: Kamelet Main 12.5s SUCCESS
Camel :: Docs 9.8s SUCCESS
Camel :: JBang :: Plugin :: Testing 8.6s SUCCESS
Camel :: JBang :: Plugin :: Kubernetes 8.2s SUCCESS
Camel :: Catalog :: Camel Report Maven Plugin 6.3s SUCCESS
Camel :: Catalog :: Camel Route Parser 5.4s SUCCESS
Camel :: YAML DSL :: Deserializers 5.0s SUCCESS
Camel :: YAML DSL :: Validator Maven Plugin 3.9s SUCCESS
Camel :: All Components Sync point 3.5s SUCCESS
Camel :: Catalog :: Maven 1.9s SUCCESS
Camel :: YAML DSL :: Maven Plugins 1.7s SUCCESS
Camel :: Catalog :: Suggest (deprecated) 1.6s SUCCESS
Camel :: Assembly 1.1s SUCCESS
Camel :: Catalog :: Dummy Component 1.0s SUCCESS
Camel :: Endpoint DSL :: Support 0.9s SUCCESS
Camel :: JBang :: Plugin :: Generate 0.8s SUCCESS
Camel :: JBang :: Plugin :: Edit 0.8s SUCCESS
Camel :: Coverage 0.8s SUCCESS
Camel :: Catalog :: Console 0.7s SUCCESS
Camel :: JBang :: Integration tests 0.6s SUCCESS
Camel :: JBang :: Main 0.6s SUCCESS
Camel :: Launcher :: Container 0.5s SUCCESS
Camel :: JBang :: Plugin :: MCP 0.5s SUCCESS
Camel :: Endpoint DSL n/a
Camel :: Integration Tests n/a
Camel :: JBang :: Core n/a
Camel :: Milo n/a

Top 20 slowest modules:

  • Camel :: JBang :: Plugin :: TUI (57.0s)
  • Camel :: Launcher (27.0s)
  • Camel :: JBang :: Plugin :: Validate (24.7s)
  • Camel :: JBang :: MCP (19.5s)
  • Camel :: YAML DSL :: Validator (18.9s)
  • Camel :: YAML DSL (18.7s)
  • Camel :: Catalog :: Camel Catalog (15.3s)
  • Camel :: Component DSL (13.4s)
  • Camel :: Kamelet Main (12.5s)
  • Camel :: Docs (9.8s)
  • Camel :: JBang :: Plugin :: Testing (8.6s)
  • Camel :: JBang :: Plugin :: Kubernetes (8.2s)
  • Camel :: Catalog :: Camel Report Maven Plugin (6.3s)
  • Camel :: Catalog :: Camel Route Parser (5.4s)
  • Camel :: YAML DSL :: Deserializers (5.0s)
  • Camel :: YAML DSL :: Validator Maven Plugin (3.9s)
  • Camel :: All Components Sync point (3.5s)
  • Camel :: Catalog :: Maven (1.9s)
  • Camel :: YAML DSL :: Maven Plugins (1.7s)
  • Camel :: Catalog :: Suggest (deprecated) (1.6s)

⚙️ View full build and test results

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

TL;DR

Approve — failed connections are now closed with disconnectAsync() without waiting for CloseSession; the follow-up commit addresses all three points from my earlier review.

Blockers

  • None

Other findings: none.

Thanks for the quick turnaround. Re-checked at 0e55717:

  1. Disposed during connect: performAndEvalConnect() now closes the connection with disposeFailed(). Nothing waits on the reconnect thread at that point, so the non-waiting close is the right choice, and camel-milo never releases Milo's shared resources on stop, so the async CloseSession still has its threads. connectionCompletedAfterDisposeIsClosed holds the connect in the relay's backlog until after dispose(), which reproduces it well.
  2. onTransferFailed now only tears down under lock when isCurrent(subscription), the same check recoverSubscription uses. Agreed that it's better than !closed, since dispose() clears connected under the lock but closes the connection outside it. Fair enough that the test server can't put the old client in Transferring and reject the transfer deterministically.
  3. The dead whenComplete branch is gone, and the comment says why.

About the red build (21, false) check: it fails in camel-jbang-core CatalogDocExamplesTest on the camel-hibernate doc examples (selectionQuery "not applicable in producer/consumer only mode"). That isn't from this PR. It came in with the new hibernate component on main and was fixed there by #28530, so a rebase on main should turn it green.

This was a static re-review; it doesn't replace CI or static analysis.

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

etzel-mes and others added 2 commits October 27, 2026 07:48
…or its session to close

A connection torn down because it failed is now disconnected with
OpcUaClient.disconnectAsync(), so the consumer that hit the failure, or
the reconnect thread, no longer holds the manager lock and waits for a
CloseSession request that the server does not answer on a dead link
before the connection is rebuilt. Disposing the manager still waits for
the clean disconnect.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…ore transfer failures of a replaced connection

A connection that performConnect() completes after the manager was
disposed is now closed without waiting, since dispose() has already
returned without it. A failed subscription transfer now tears down the
connection only when the subscription still belongs to the current
connection: a client that is being disconnected keeps recovering its
session until its CloseSession goes through, and its transfer can fail
after it was replaced. The disconnectAsync() future always completes
normally, so its unreachable error branch is dropped.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@davsclaus
davsclaus force-pushed the fix/CAMEL-26112-milo-disconnect-outside-lock branch from 0e55717 to 5961034 Compare October 27, 2026 06:48
@davsclaus
davsclaus merged commit 2153de2 into apache:main Oct 27, 2026
6 checks passed
@davsclaus davsclaus added this to the 4.24.0 milestone Oct 27, 2026
@davsclaus davsclaus added the enhancement New feature or request label Oct 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

components enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants