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

davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git


The following commit(s) were added to refs/heads/main by this push:
     new 2f3adc3bd1ad CAMEL-26573: camel-vertx-websocket - connect and 
disconnect consumers of a shared host one at a time (#29198)
2f3adc3bd1ad is described below

commit 2f3adc3bd1add3b162522b8cff24cc59ff8f1887
Author: allthingssecurity <[email protected]>
AuthorDate: Thu Nov 5 12:34:24 2026 +0530

    CAMEL-26573: camel-vertx-websocket - connect and disconnect consumers of a 
shared host one at a time (#29198)
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../vertx/websocket/VertxWebsocketComponent.java   |  37 ++++-
 .../VertxWebsocketSharedHostStartStopRaceTest.java | 179 +++++++++++++++++++++
 2 files changed, 212 insertions(+), 4 deletions(-)

diff --git 
a/components/camel-vertx/camel-vertx-websocket/src/main/java/org/apache/camel/component/vertx/websocket/VertxWebsocketComponent.java
 
b/components/camel-vertx/camel-vertx-websocket/src/main/java/org/apache/camel/component/vertx/websocket/VertxWebsocketComponent.java
index 564a81767759..195cf416727d 100644
--- 
a/components/camel-vertx/camel-vertx-websocket/src/main/java/org/apache/camel/component/vertx/websocket/VertxWebsocketComponent.java
+++ 
b/components/camel-vertx/camel-vertx-websocket/src/main/java/org/apache/camel/component/vertx/websocket/VertxWebsocketComponent.java
@@ -20,6 +20,8 @@ import java.net.URI;
 import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantLock;
 
 import io.vertx.core.Vertx;
 import io.vertx.core.VertxOptions;
@@ -41,6 +43,9 @@ import static 
org.apache.camel.component.vertx.websocket.VertxWebsocketHelper.cr
 public class VertxWebsocketComponent extends DefaultComponent implements 
SSLContextParametersAware {
 
     private final Map<VertxWebsocketHostKey, VertxWebsocketHost> 
vertxHostRegistry = new ConcurrentHashMap<>();
+    // connect and disconnect of the consumers of one host and port run one at 
a time (route policies start and stop
+    // consumers without the CamelContext lock); a lock is kept after its host 
goes, so a new host gets the same lock
+    private final Map<VertxWebsocketHostKey, Lock> hostLocks = new 
ConcurrentHashMap<>();
     private boolean managedVertx;
 
     @Metadata(label = "advanced")
@@ -162,6 +167,32 @@ public class VertxWebsocketComponent extends 
DefaultComponent implements SSLCont
     }
 
     public void connectConsumer(VertxWebsocketConsumer consumer) {
+        // without the lock, a disconnect between the lookup and the connect 
could remove the host from the registry
+        Lock lock = hostLock(consumer);
+        lock.lock();
+        try {
+            doConnectConsumer(consumer);
+        } finally {
+            lock.unlock();
+        }
+    }
+
+    public void disconnectConsumer(VertxWebsocketConsumer consumer) {
+        Lock lock = hostLock(consumer);
+        lock.lock();
+        try {
+            doDisconnectConsumer(consumer);
+        } finally {
+            lock.unlock();
+        }
+    }
+
+    private Lock hostLock(VertxWebsocketConsumer consumer) {
+        VertxWebsocketHostKey hostKey = 
createHostKey(consumer.getEndpoint().getConfiguration().getWebsocketURI());
+        return hostLocks.computeIfAbsent(hostKey, key -> new ReentrantLock());
+    }
+
+    private void doConnectConsumer(VertxWebsocketConsumer consumer) {
         VertxWebsocketEndpoint endpoint = consumer.getEndpoint();
         VertxWebsocketConfiguration configuration = 
endpoint.getConfiguration();
         VertxWebsocketHostKey hostKey = 
createHostKey(configuration.getWebsocketURI());
@@ -213,7 +244,7 @@ public class VertxWebsocketComponent extends 
DefaultComponent implements SSLCont
         }
     }
 
-    public void disconnectConsumer(VertxWebsocketConsumer consumer) {
+    private void doDisconnectConsumer(VertxWebsocketConsumer consumer) {
         VertxWebsocketEndpoint endpoint = consumer.getEndpoint();
         VertxWebsocketConfiguration configuration = 
endpoint.getConfiguration();
         VertxWebsocketHostKey hostKey = 
createHostKey(configuration.getWebsocketURI());
@@ -223,9 +254,7 @@ public class VertxWebsocketComponent extends 
DefaultComponent implements SSLCont
             vertxWebsocketHost.disconnect(consumer);
 
             // every consumer on this host and port shares the one host, which 
stops its server as its last
-            // consumer goes. Forgetting it any earlier would leave the 
consumers still on it unable to disconnect.
-            // computeIfPresent decides that against the same key 
connectConsumer computes on, so a consumer
-            // connecting at this moment either keeps the host or gets a fresh 
one, never a forgotten one
+            // consumer goes. Forgetting it any earlier would leave the 
consumers still on it unable to disconnect
             vertxHostRegistry.computeIfPresent(hostKey, (key, host) -> 
host.isServingConsumers() ? host : null);
         }
     }
diff --git 
a/components/camel-vertx/camel-vertx-websocket/src/test/java/org/apache/camel/component/vertx/websocket/VertxWebsocketSharedHostStartStopRaceTest.java
 
b/components/camel-vertx/camel-vertx-websocket/src/test/java/org/apache/camel/component/vertx/websocket/VertxWebsocketSharedHostStartStopRaceTest.java
new file mode 100644
index 000000000000..2b1d0b91a156
--- /dev/null
+++ 
b/components/camel-vertx/camel-vertx-websocket/src/test/java/org/apache/camel/component/vertx/websocket/VertxWebsocketSharedHostStartStopRaceTest.java
@@ -0,0 +1,179 @@
+/*
+ * 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.component.vertx.websocket;
+
+import java.net.ConnectException;
+import java.net.Socket;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.FutureTask;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.locks.AbstractQueuedSynchronizer;
+import java.util.concurrent.locks.LockSupport;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.Consumer;
+import org.apache.camel.RoutesBuilder;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.support.jsse.SSLContextParameters;
+import org.apache.camel.support.service.ServiceHelper;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+import static org.awaitility.Awaitility.await;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Route policies start and stop consumers without the CamelContext lock, so 
consumers sharing a host and port can
+ * connect and disconnect at the same time. A consumer must not end up on a 
host that a disconnect removed from the
+ * registry meanwhile, as then its stop would not close the server.
+ */
+class VertxWebsocketSharedHostStartStopRaceTest extends 
VertxWebSocketTestSupport {
+
+    private final CountDownLatch connectReached = new CountDownLatch(1);
+    private final CountDownLatch connectReleased = new CountDownLatch(1);
+    private final Map<FutureTask<Void>, Thread> taskThreads = new 
ConcurrentHashMap<>();
+    private volatile Consumer pauseConnectOf;
+
+    @AfterEach
+    void release() {
+        connectReleased.countDown();
+    }
+
+    @Override
+    protected CamelContext createCamelContext() throws Exception {
+        CamelContext camelContext = super.createCamelContext();
+        camelContext.addComponent("vertx-websocket", new PausingComponent());
+        return camelContext;
+    }
+
+    @Test
+    void startWhileTheLastConsumerStops() throws Exception {
+        Consumer a = context.getRoute("a").getConsumer();
+        Consumer b = context.getRoute("b").getConsumer();
+        ServiceHelper.stopService(b);
+
+        // b gets the host of a from the registry, and pauses before it 
connects
+        pauseConnectOf = b;
+        FutureTask<Void> startB = run(() -> ServiceHelper.startService(b));
+        assertTrue(connectReached.await(10, TimeUnit.SECONDS));
+
+        // a is the last consumer of the host
+        FutureTask<Void> stopA = run(() -> ServiceHelper.stopService(a));
+        awaitDoneOrWaitingOnALock(stopA);
+        connectReleased.countDown();
+        startB.get(10, TimeUnit.SECONDS);
+        stopA.get(10, TimeUnit.SECONDS);
+
+        ServiceHelper.stopService(b);
+
+        assertThrows(ConnectException.class, () -> new Socket("localhost", 
port.getPort()).close(),
+                "a server still listens after both consumers stopped");
+        assertTrue(isRegistryEmpty());
+    }
+
+    @Test
+    void startWhileAnotherConsumerFailsToStart() throws Exception {
+        Consumer a = context.getRoute("a").getConsumer();
+        ServiceHelper.stopService(a, context.getRoute("b").getConsumer());
+        // the host refuses this consumer, as it has other 
SSLContextParameters, and its failed start stops it
+        VertxWebsocketEndpoint tlsEndpoint = context.getEndpoint(
+                "vertx-websocket:localhost:" + port.getPort() + "/test/tls", 
VertxWebsocketEndpoint.class);
+        tlsEndpoint.getConfiguration().setSslContextParameters(new 
SSLContextParameters());
+        Consumer tls = tlsEndpoint.createConsumer(exchange -> {
+        });
+
+        // a creates the host, and pauses before it connects
+        pauseConnectOf = a;
+        FutureTask<Void> startA = run(() -> ServiceHelper.startService(a));
+        assertTrue(connectReached.await(10, TimeUnit.SECONDS));
+
+        FutureTask<Void> startTls = run(() -> ServiceHelper.startService(tls));
+        awaitDoneOrWaitingOnALock(startTls);
+        connectReleased.countDown();
+        startA.get(10, TimeUnit.SECONDS);
+        Exception refused = assertThrows(Exception.class, () -> 
startTls.get(10, TimeUnit.SECONDS));
+        assertTrue(refused.getMessage().contains("SSLContextParameters"), 
"unexpected failure: " + refused);
+
+        ServiceHelper.stopService(a);
+
+        assertThrows(ConnectException.class, () -> new Socket("localhost", 
port.getPort()).close(),
+                "a server still listens after the consumer stopped");
+        assertTrue(isRegistryEmpty());
+    }
+
+    private FutureTask<Void> run(Runnable runnable) {
+        FutureTask<Void> task = new FutureTask<>(runnable, null);
+        Thread thread = new Thread(task, "start-stop-race");
+        thread.setDaemon(true);
+        taskThreads.put(task, thread);
+        thread.start();
+        return task;
+    }
+
+    /**
+     * Waits until the task is done or parked on a lock: with the fix, the 
lock of the host key that the paused connect
+     * holds (a stop that waits for the server to close waits on a future, not 
on a lock)
+     */
+    private void awaitDoneOrWaitingOnALock(FutureTask<Void> task) {
+        Thread thread = taskThreads.get(task);
+        await().atMost(10, TimeUnit.SECONDS)
+                .until(() -> task.isDone() || LockSupport.getBlocker(thread) 
instanceof AbstractQueuedSynchronizer);
+    }
+
+    private boolean isRegistryEmpty() {
+        return context.getComponent("vertx-websocket", 
VertxWebsocketComponent.class).getVertxHostRegistry().isEmpty();
+    }
+
+    @Override
+    protected RoutesBuilder createRouteBuilder() {
+        return new RouteBuilder() {
+            @Override
+            public void configure() {
+                fromF("vertx-websocket:localhost:%d/test/a", 
port.getPort()).routeId("a").to("mock:a");
+                fromF("vertx-websocket:localhost:%d/test/b", 
port.getPort()).routeId("b").to("mock:b");
+            }
+        };
+    }
+
+    /**
+     * Pauses the chosen consumer after it got its host from the registry, 
before the host connects it
+     */
+    private class PausingComponent extends VertxWebsocketComponent {
+        @Override
+        protected VertxWebsocketHost createVertxWebsocketHost(
+                VertxWebsocketHostConfiguration hostConfiguration, 
VertxWebsocketHostKey hostKey) {
+            return new VertxWebsocketHost(getCamelContext(), 
hostConfiguration, hostKey) {
+                @Override
+                public void connect(VertxWebsocketConsumer consumer) {
+                    if (consumer == pauseConnectOf) {
+                        pauseConnectOf = null;
+                        connectReached.countDown();
+                        try {
+                            connectReleased.await(10, TimeUnit.SECONDS);
+                        } catch (InterruptedException e) {
+                            Thread.currentThread().interrupt();
+                        }
+                    }
+                    super.connect(consumer);
+                }
+            };
+        }
+    }
+}

Reply via email to