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
