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

Croway pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel-spring-boot.git

commit b2b3db1d0bb9f081f77f32302c0fd316a404d8d1
Author: croway <[email protected]>
AuthorDate: Thu Oct 1 18:20:59 2026 +0200

    CAMEL-25197: camel-cli-connector-starter - review: require a Jakarta 
WebSocket implementation
    
    - The Spring client is registered only when a Jakarta WebSocket 
implementation is found (a
      ContainerProvider service, as ContainerProvider.getWebSocketContainer() 
looks it up), not
      only the Jakarta WebSocket API: an application with spring-websocket and 
the API alone kept
      failing to connect instead of using the JDK client. Not registered either 
with
      camel.cli.websocket.client=jdk.
    - Warn when camel.cli.websocket.ssl-bundle is set but the JDK client is 
used.
    - Test camel stop with the file transport (lock file deleted, the context 
is closed).
    - Connect on daemon threads: when the application fails to start, Camel 
never stops the
      connector, which keeps reconnecting (as CAMEL-25230 for the file 
transport thread).
    - Comments: Camel stopped by both sigterm() and close(), Camel defaults of 
the options.
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../connector/CliConnectorAutoConfiguration.java   | 35 ++++++++--
 .../cli/connector/CliConnectorConfiguration.java   |  3 +-
 .../OnSpringWebSocketClientCondition.java          | 74 ++++++++++++++++++++++
 .../cli/connector/SpringCliWebSocketClient.java    |  6 +-
 .../cli/connector/SpringLocalCliConnector.java     |  2 +
 .../CliConnectorAutoConfigurationTest.java         | 38 +++++++++++
 .../connector/CliConnectorSpringLifecycleTest.java | 25 ++++++++
 .../CliConnectorWebSocketJdkClientTest.java        |  2 +-
 .../connector/SpringCliWebSocketClientTest.java    | 14 ++++
 9 files changed, 191 insertions(+), 8 deletions(-)

diff --git 
a/dsl-starter/camel-cli-connector-starter/src/main/java/org/apache/camel/springboot/cli/connector/CliConnectorAutoConfiguration.java
 
b/dsl-starter/camel-cli-connector-starter/src/main/java/org/apache/camel/springboot/cli/connector/CliConnectorAutoConfiguration.java
index bd3886d1950..db367a4ba20 100644
--- 
a/dsl-starter/camel-cli-connector-starter/src/main/java/org/apache/camel/springboot/cli/connector/CliConnectorAutoConfiguration.java
+++ 
b/dsl-starter/camel-cli-connector-starter/src/main/java/org/apache/camel/springboot/cli/connector/CliConnectorAutoConfiguration.java
@@ -23,15 +23,17 @@ import java.util.jar.Manifest;
 
 import org.apache.camel.cli.connector.CliWebSocketClient;
 import org.apache.camel.spi.CliConnectorFactory;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.ObjectProvider;
 import org.springframework.boot.SpringBootVersion;
 import org.springframework.boot.autoconfigure.AutoConfigureBefore;
-import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
 import 
org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
 import 
org.springframework.boot.context.properties.EnableConfigurationProperties;
 import org.springframework.boot.ssl.SslBundles;
 import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Conditional;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.context.support.AbstractApplicationContext;
 
@@ -42,6 +44,8 @@ import 
org.springframework.context.support.AbstractApplicationContext;
 @EnableConfigurationProperties({ CliConnectorConfiguration.class })
 public class CliConnectorAutoConfiguration {
 
+    private static final Logger LOG = 
LoggerFactory.getLogger(CliConnectorAutoConfiguration.class);
+
     @Bean
     @ConditionalOnMissingBean(CliConnectorFactory.class)
     public CliConnectorFactory cliConnectorFactory(AbstractApplicationContext 
applicationContext,
@@ -51,6 +55,7 @@ public class CliConnectorAutoConfiguration {
         answer.setEnabled(config.getEnabled());
         answer.setRuntime("Spring Boot");
         answer.setRuntimeVersion(SpringBootVersion.getVersion());
+        warnIfSslBundleIgnored(config, applicationContext.getClassLoader());
 
         // if packaged as fat-jar then we need to know what was the main class 
that started this integration
         try {
@@ -73,14 +78,34 @@ public class CliConnectorAutoConfiguration {
         return answer;
     }
 
+    /**
+     * The SSL bundle is only used by the Spring WebSocket client: without it, 
a wss:// tool is trusted with the JVM
+     * default trust store, and the TLS failure would not say why.
+     */
+    private static void warnIfSslBundleIgnored(CliConnectorConfiguration 
config, ClassLoader classLoader) {
+        String bundle = config.getWebsocket().getSslBundle();
+        if (!"websocket".equalsIgnoreCase(config.getTransport()) || bundle == 
null || bundle.isBlank()) {
+            return;
+        }
+        String reason = null;
+        if ("jdk".equalsIgnoreCase(config.getWebsocket().getClient())) {
+            reason = "camel.cli.websocket.client=jdk";
+        } else if (!OnSpringWebSocketClientCondition.isAvailable(classLoader)) 
{
+            reason = "the application has no spring-websocket and Jakarta 
WebSocket implementation";
+        }
+        if (reason != null) {
+            LOG.warn("camel.cli.websocket.ssl-bundle={} is ignored: the JDK 
WebSocket client is used ({}),"
+                     + " which trusts the JVM default trust store",
+                    bundle, reason);
+        }
+    }
+
     /**
      * The Spring WebSocket client for the websocket transport, when the 
application has spring-websocket and a Jakarta
-     * WebSocket client (such as Tomcat with spring-boot-starter-websocket). 
Otherwise Camel uses the JDK client.
+     * WebSocket implementation (such as Tomcat with 
spring-boot-starter-websocket). Otherwise Camel uses the JDK client.
      */
     @Configuration(proxyBeanMethods = false)
-    @ConditionalOnClass(name = {
-            
"org.springframework.web.socket.client.standard.StandardWebSocketClient",
-            "jakarta.websocket.ContainerProvider" })
+    @Conditional(OnSpringWebSocketClientCondition.class)
     static class SpringWebSocketClientConfiguration {
 
         @Bean
diff --git 
a/dsl-starter/camel-cli-connector-starter/src/main/java/org/apache/camel/springboot/cli/connector/CliConnectorConfiguration.java
 
b/dsl-starter/camel-cli-connector-starter/src/main/java/org/apache/camel/springboot/cli/connector/CliConnectorConfiguration.java
index 5f3bccf4720..91e1edc4c5f 100644
--- 
a/dsl-starter/camel-cli-connector-starter/src/main/java/org/apache/camel/springboot/cli/connector/CliConnectorConfiguration.java
+++ 
b/dsl-starter/camel-cli-connector-starter/src/main/java/org/apache/camel/springboot/cli/connector/CliConnectorConfiguration.java
@@ -65,7 +65,8 @@ public class CliConnectorConfiguration {
 
     /**
      * Options of the WebSocket transport. Camel reads them from the Spring 
environment: they are declared here so they
-     * are documented and completed by IDEs.
+     * are documented and completed by IDEs. The defaults must be kept in line 
with the ones of
+     * org.apache.camel.cli.connector.WebSocketCliConnectorTransport 
(camel-cli-connector), which are the ones used.
      */
     public static class Websocket {
 
diff --git 
a/dsl-starter/camel-cli-connector-starter/src/main/java/org/apache/camel/springboot/cli/connector/OnSpringWebSocketClientCondition.java
 
b/dsl-starter/camel-cli-connector-starter/src/main/java/org/apache/camel/springboot/cli/connector/OnSpringWebSocketClientCondition.java
new file mode 100644
index 00000000000..020c81406ec
--- /dev/null
+++ 
b/dsl-starter/camel-cli-connector-starter/src/main/java/org/apache/camel/springboot/cli/connector/OnSpringWebSocketClientCondition.java
@@ -0,0 +1,74 @@
+/*
+ * 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.springboot.cli.connector;
+
+import java.util.ServiceConfigurationError;
+import java.util.ServiceLoader;
+
+import org.springframework.boot.autoconfigure.condition.ConditionOutcome;
+import org.springframework.boot.autoconfigure.condition.SpringBootCondition;
+import org.springframework.context.annotation.ConditionContext;
+import org.springframework.core.type.AnnotatedTypeMetadata;
+import org.springframework.util.ClassUtils;
+
+/**
+ * Matches when the Spring WebSocket client can be used: spring-websocket, and 
a Jakarta WebSocket implementation (such
+ * as Tomcat's), not only the Jakarta WebSocket API, which some applications 
have without an implementation. Not when
+ * <tt>camel.cli.websocket.client=jdk</tt> asks for the JDK client.
+ */
+class OnSpringWebSocketClientCondition extends SpringBootCondition {
+
+    static final String CLIENT_CLASS = 
"org.springframework.web.socket.client.standard.StandardWebSocketClient";
+    static final String PROVIDER_CLASS = "jakarta.websocket.ContainerProvider";
+
+    @Override
+    public ConditionOutcome getMatchOutcome(ConditionContext context, 
AnnotatedTypeMetadata metadata) {
+        if 
("jdk".equalsIgnoreCase(context.getEnvironment().getProperty("camel.cli.websocket.client")))
 {
+            return ConditionOutcome.noMatch("camel.cli.websocket.client=jdk");
+        }
+        String missing = missing(context.getClassLoader());
+        return missing == null
+                ? ConditionOutcome.match("Spring WebSocket client and a 
Jakarta WebSocket implementation found")
+                : ConditionOutcome.noMatch(missing);
+    }
+
+    /**
+     * Whether the Spring WebSocket client can be used.
+     */
+    static boolean isAvailable(ClassLoader classLoader) {
+        return missing(classLoader) == null;
+    }
+
+    private static String missing(ClassLoader classLoader) {
+        if (!ClassUtils.isPresent(CLIENT_CLASS, classLoader)) {
+            return "spring-websocket not found";
+        }
+        if (!ClassUtils.isPresent(PROVIDER_CLASS, classLoader)) {
+            return "Jakarta WebSocket API not found";
+        }
+        // as ContainerProvider.getWebSocketContainer() looks it up, without 
creating it
+        try {
+            Class<?> provider = ClassUtils.forName(PROVIDER_CLASS, 
classLoader);
+            if (ServiceLoader.load(provider, 
classLoader).stream().findFirst().isEmpty()) {
+                return "no Jakarta WebSocket implementation (" + 
PROVIDER_CLASS + " service) found";
+            }
+        } catch (ClassNotFoundException | ServiceConfigurationError | 
LinkageError e) {
+            return "no usable Jakarta WebSocket implementation: " + e;
+        }
+        return null;
+    }
+}
diff --git 
a/dsl-starter/camel-cli-connector-starter/src/main/java/org/apache/camel/springboot/cli/connector/SpringCliWebSocketClient.java
 
b/dsl-starter/camel-cli-connector-starter/src/main/java/org/apache/camel/springboot/cli/connector/SpringCliWebSocketClient.java
index 4cfec9ba45c..61d7017e1f5 100644
--- 
a/dsl-starter/camel-cli-connector-starter/src/main/java/org/apache/camel/springboot/cli/connector/SpringCliWebSocketClient.java
+++ 
b/dsl-starter/camel-cli-connector-starter/src/main/java/org/apache/camel/springboot/cli/connector/SpringCliWebSocketClient.java
@@ -137,7 +137,11 @@ public class SpringCliWebSocketClient implements 
CliWebSocketClient {
                     // pings and close frames are blocking sends
                     properties.put(BLOCKING_SEND_TIMEOUT_PROPERTY, timeout);
                     answer.setUserProperties(properties);
-                    answer.setTaskExecutor(new 
SimpleAsyncTaskExecutor("CliConnectorWebSocketConnect-"));
+                    SimpleAsyncTaskExecutor executor = new 
SimpleAsyncTaskExecutor("CliConnectorWebSocketConnect-");
+                    // as the threads of the transport: when the application 
fails to start, Camel never stops the
+                    // connector, which keeps reconnecting, and a connect in 
progress must not keep the JVM alive
+                    executor.setDaemon(true);
+                    answer.setTaskExecutor(executor);
                     client = answer;
                 }
             }
diff --git 
a/dsl-starter/camel-cli-connector-starter/src/main/java/org/apache/camel/springboot/cli/connector/SpringLocalCliConnector.java
 
b/dsl-starter/camel-cli-connector-starter/src/main/java/org/apache/camel/springboot/cli/connector/SpringLocalCliConnector.java
index 6488693fa08..66ff9d2856e 100644
--- 
a/dsl-starter/camel-cli-connector-starter/src/main/java/org/apache/camel/springboot/cli/connector/SpringLocalCliConnector.java
+++ 
b/dsl-starter/camel-cli-connector-starter/src/main/java/org/apache/camel/springboot/cli/connector/SpringLocalCliConnector.java
@@ -48,6 +48,8 @@ public class SpringLocalCliConnector extends 
LocalCliConnector {
     @Override
     public void sigterm() {
         try {
+            // starts a thread that stops Camel, while close() below stops it 
too (as stop() did): Camel stops a context
+            // under a lock, so whichever comes second waits for the first 
one, then finds it stopped
             super.sigterm();
         } finally {
             // close, not only stop: a stopped context keeps the JVM running 
(for example the embedded web server),
diff --git 
a/dsl-starter/camel-cli-connector-starter/src/test/java/org/apache/camel/springboot/cli/connector/CliConnectorAutoConfigurationTest.java
 
b/dsl-starter/camel-cli-connector-starter/src/test/java/org/apache/camel/springboot/cli/connector/CliConnectorAutoConfigurationTest.java
index 844353943b2..d500ec2ebef 100644
--- 
a/dsl-starter/camel-cli-connector-starter/src/test/java/org/apache/camel/springboot/cli/connector/CliConnectorAutoConfigurationTest.java
+++ 
b/dsl-starter/camel-cli-connector-starter/src/test/java/org/apache/camel/springboot/cli/connector/CliConnectorAutoConfigurationTest.java
@@ -20,6 +20,7 @@ import javax.net.ssl.SSLContext;
 
 import org.apache.camel.cli.connector.CliWebSocketClient;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
 import org.springframework.boot.autoconfigure.AutoConfigurations;
 import org.springframework.boot.ssl.DefaultSslBundleRegistry;
 import org.springframework.boot.ssl.SslBundle;
@@ -27,10 +28,14 @@ import org.springframework.boot.ssl.SslBundles;
 import org.springframework.boot.ssl.SslStoreBundle;
 import org.springframework.boot.test.context.FilteredClassLoader;
 import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+import org.springframework.boot.test.system.CapturedOutput;
+import org.springframework.boot.test.system.OutputCaptureExtension;
+import org.springframework.core.io.ClassPathResource;
 import org.springframework.web.socket.client.standard.StandardWebSocketClient;
 
 import static org.assertj.core.api.Assertions.assertThat;
 
+@ExtendWith(OutputCaptureExtension.class)
 class CliConnectorAutoConfigurationTest {
 
     private final ApplicationContextRunner runner = new 
ApplicationContextRunner()
@@ -55,6 +60,39 @@ class CliConnectorAutoConfigurationTest {
                 .run(context -> 
assertThat(context).hasNotFailed().doesNotHaveBean(CliWebSocketClient.class));
     }
 
+    @Test
+    void leavesTheJdkClientToCamelWithoutJakartaWebSocketImplementation() {
+        // spring-websocket and the Jakarta WebSocket API, but no 
implementation (no Tomcat, Jetty, ...)
+        runner.withClassLoader(new FilteredClassLoader(
+                new ClassPathResource("META-INF/services/" + 
OnSpringWebSocketClientCondition.PROVIDER_CLASS)))
+                .run(context -> 
assertThat(context).hasNotFailed().doesNotHaveBean(CliWebSocketClient.class));
+    }
+
+    @Test
+    void noSpringClientWhenTheJdkClientIsAskedFor() {
+        runner.withPropertyValues("camel.cli.websocket.client=jdk")
+                .run(context -> 
assertThat(context).hasNotFailed().doesNotHaveBean(CliWebSocketClient.class));
+    }
+
+    @Test
+    void warnsThatTheSslBundleIsIgnoredWithTheJdkClient(CapturedOutput output) 
{
+        runner.withPropertyValues("camel.cli.transport=websocket", 
"camel.cli.websocket.ssl-bundle=tool",
+                "camel.cli.websocket.client=jdk")
+                .run(context -> assertThat(output).contains(
+                        "camel.cli.websocket.ssl-bundle=tool is ignored: the 
JDK WebSocket client is used"
+                                                            + " 
(camel.cli.websocket.client=jdk)"));
+    }
+
+    @Test
+    void warnsThatTheSslBundleIsIgnoredWithoutSpringWebSocket(CapturedOutput 
output) {
+        runner.withClassLoader(new 
FilteredClassLoader(StandardWebSocketClient.class))
+                .withPropertyValues("camel.cli.transport=websocket", 
"camel.cli.websocket.ssl-bundle=tool")
+                .run(context -> assertThat(output).contains(
+                        "camel.cli.websocket.ssl-bundle=tool is ignored: the 
JDK WebSocket client is used"
+                                                            + " (the 
application has no spring-websocket and Jakarta"
+                                                            + " WebSocket 
implementation)"));
+    }
+
     @Test
     void keepsTheClientOfTheApplication() {
         CliWebSocketClient custom = new SpringCliWebSocketClient();
diff --git 
a/dsl-starter/camel-cli-connector-starter/src/test/java/org/apache/camel/springboot/cli/connector/CliConnectorSpringLifecycleTest.java
 
b/dsl-starter/camel-cli-connector-starter/src/test/java/org/apache/camel/springboot/cli/connector/CliConnectorSpringLifecycleTest.java
index 755aa26b56f..865e8e3d164 100644
--- 
a/dsl-starter/camel-cli-connector-starter/src/test/java/org/apache/camel/springboot/cli/connector/CliConnectorSpringLifecycleTest.java
+++ 
b/dsl-starter/camel-cli-connector-starter/src/test/java/org/apache/camel/springboot/cli/connector/CliConnectorSpringLifecycleTest.java
@@ -16,6 +16,8 @@
  */
 package org.apache.camel.springboot.cli.connector;
 
+import java.io.File;
+import java.nio.file.Path;
 import java.util.Map;
 import java.util.concurrent.TimeUnit;
 
@@ -23,6 +25,7 @@ import org.apache.camel.spring.boot.CamelAutoConfiguration;
 import org.apache.camel.util.json.JsonObject;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
 import org.springframework.boot.WebApplicationType;
 import org.springframework.boot.builder.SpringApplicationBuilder;
 import org.springframework.context.ConfigurableApplicationContext;
@@ -67,6 +70,28 @@ class CliConnectorSpringLifecycleTest {
         await().atMost(20, TimeUnit.SECONDS).untilAsserted(() -> 
assertThat(context.isActive()).isFalse());
     }
 
+    @Test
+    void camelStopClosesTheApplicationContext(@TempDir Path home) throws 
Exception {
+        // the file transport (camel stop deletes the lock file 
~/.camel/{pid}): keep the test away from the real one
+        String oldHome = System.getProperty("user.home");
+        System.setProperty("user.home", home.toString());
+        try {
+            context = new SpringApplicationBuilder(
+                    CamelAutoConfiguration.class, 
CliConnectorAutoConfiguration.class,
+                    CliConnectorWebSocketTestSupport.Routes.class)
+                    .web(WebApplicationType.NONE)
+                    .run();
+            File lockFile = 
home.resolve(".camel").resolve(String.valueOf(ProcessHandle.current().pid())).toFile();
+            await().atMost(20, TimeUnit.SECONDS).until(lockFile::exists);
+
+            assertThat(lockFile.delete()).isTrue();
+
+            await().atMost(20, TimeUnit.SECONDS).untilAsserted(() -> 
assertThat(context.isActive()).isFalse());
+        } finally {
+            System.setProperty("user.home", oldHome);
+        }
+    }
+
     @Test
     void refusesTheWebSocketTransportWithTheSpringProdProfile() throws 
Exception {
         tool.start();
diff --git 
a/dsl-starter/camel-cli-connector-starter/src/test/java/org/apache/camel/springboot/cli/connector/CliConnectorWebSocketJdkClientTest.java
 
b/dsl-starter/camel-cli-connector-starter/src/test/java/org/apache/camel/springboot/cli/connector/CliConnectorWebSocketJdkClientTest.java
index 7735864adf4..aeffd0fa765 100644
--- 
a/dsl-starter/camel-cli-connector-starter/src/test/java/org/apache/camel/springboot/cli/connector/CliConnectorWebSocketJdkClientTest.java
+++ 
b/dsl-starter/camel-cli-connector-starter/src/test/java/org/apache/camel/springboot/cli/connector/CliConnectorWebSocketJdkClientTest.java
@@ -35,7 +35,7 @@ import org.springframework.test.annotation.DirtiesContext;
                 properties = {
                         "camel.cli.transport=websocket",
                         "camel.cli.websocket.snapshot-interval=200",
-                 "camel.cli.websocket.client=jdk" })
+                        "camel.cli.websocket.client=jdk" })
 class CliConnectorWebSocketJdkClientTest extends 
CliConnectorWebSocketTestSupport {
 
     @Override
diff --git 
a/dsl-starter/camel-cli-connector-starter/src/test/java/org/apache/camel/springboot/cli/connector/SpringCliWebSocketClientTest.java
 
b/dsl-starter/camel-cli-connector-starter/src/test/java/org/apache/camel/springboot/cli/connector/SpringCliWebSocketClientTest.java
index b9e6f59a47c..0c22c2d1a70 100644
--- 
a/dsl-starter/camel-cli-connector-starter/src/test/java/org/apache/camel/springboot/cli/connector/SpringCliWebSocketClientTest.java
+++ 
b/dsl-starter/camel-cli-connector-starter/src/test/java/org/apache/camel/springboot/cli/connector/SpringCliWebSocketClientTest.java
@@ -97,6 +97,20 @@ class SpringCliWebSocketClientTest {
         }
     }
 
+    @Test
+    void connectsOnDaemonThreads() throws Exception {
+        // the connector is not stopped when the application fails to start: 
connecting must not keep the JVM alive
+        SpringCliWebSocketClient slow = new SpringCliWebSocketClient(null, 
5000);
+        try (ServerSocket server = new ServerSocket(0, 1, 
InetAddress.getLoopbackAddress())) {
+            slow.connect(URI.create("ws://127.0.0.1:" + server.getLocalPort() 
+ "/connect"), Map.of(), listener);
+
+            await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> 
assertThat(Thread.getAllStackTraces().keySet())
+                    .filteredOn(t -> 
t.getName().startsWith("CliConnectorWebSocketConnect-"))
+                    .isNotEmpty()
+                    .allMatch(Thread::isDaemon));
+        }
+    }
+
     @Test
     void receivesALargeMessageSentInASingleFrame() throws Exception {
         // Tomcat (the tool server of the other tests) fragments what it 
sends: some tools send one frame per message

Reply via email to