allthingssecurity commented on code in PR #27800:
URL: https://github.com/apache/camel/pull/27800#discussion_r4267168855


##########
components/camel-jmx/src/main/java/org/apache/camel/component/jmx/JMXConsumer.java:
##########
@@ -208,10 +230,44 @@ public void handleNotification(Notification notification, 
Object handback) {
     }
 
     /**
-     * Schedules an attempt to re-initialize a lost connection after the 
reconnect delay
+     * The connector lost its connection and opened another one by itself (the 
listener is added after the connector is
+     * connected, so the first connection is not reported here). The new 
connection has another id, which the following
+     * notifications carry, and the connector has registered the listener of 
this consumer again only if the MBean
+     * existed at that moment.
+     */
+    private void connectorReconnected(String connectionId) {
+        if (connectionId == null || connectionId.equals(mConnectionId)) {
+            return;
+        }
+        mConnectionId = connectionId;
+        if (!isRunAllowed()) {
+            return;
+        }
+        if (mJmxEndpoint.isReconnectOnConnectionFailure()) {

Review Comment:
   Good point, done in 50ecdcec4f32. After an `OPENED` with a new id the 
scheduled task now subscribes again on the current
   connection: `removeNotificationListener(objectName, this)` (a 
`ListenerNotFoundException` is ignored: the connector
   did not register it again), then `addNotificationListener()`. Only if that 
fails does it fall back to
   `initNetworkConnection()` plus subscribe, as before. The remove and add go 
through the connector's
   `MBeanServerConnection`, which uses the connection that the connector 
restored. A `FAILED`/`CLOSED` that arrives while
   this task is pending is merged into it by the `compareAndSet` as before; on 
a dead connection the remove fails and the
   task reconnects.
   
   Tests in `JMXConsumerReconnectTest`, replacing the previous `OPENED` test: 
with the listener still registered, no new
   connection for three seconds and one notification delivered once; with the 
listener removed before the `OPENED`, the
   consumer is subscribed again without a new connection; with the MBean 
unregistered, the consumer connects again until
   the MBean is back, then stops. With the consumer of the previous commit the 
first two fail (`the consumer connected again although it was subscribed on a 
working connection`).
   camel-jmx: 60 run, 0 failures.
   
   _Claude Code on behalf of allthingssecurity_



##########
components/camel-jmx/src/test/java/org/apache/camel/component/jmx/JMXConsumerReconnectTest.java:
##########
@@ -0,0 +1,307 @@
+/*
+ * 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.jmx;
+
+import java.rmi.NoSuchObjectException;
+import java.rmi.registry.LocateRegistry;
+import java.rmi.registry.Registry;
+import java.rmi.server.UnicastRemoteObject;
+import java.util.Collections;
+import java.util.concurrent.Semaphore;
+import java.util.concurrent.TimeUnit;
+
+import javax.management.InstanceNotFoundException;
+import javax.management.MBeanServerFactory;
+import javax.management.NotificationListener;
+import javax.management.remote.JMXConnectionNotification;
+import javax.management.remote.JMXConnectorServer;
+import javax.management.remote.JMXConnectorServerFactory;
+import javax.management.remote.JMXServiceURL;
+
+import org.apache.camel.Route;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.test.AvailablePortFinder;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * A remote JMX consumer with reconnectOnConnectionFailure=true must replace a 
lost connection once: one reconnect for
+ * the "failed" and "closed" notifications that the connector sends for one 
lost connection, no reconnect for the
+ * connection it closes itself, and none after it was stopped. The connector 
server counts the client connections that
+ * it accepts.
+ */
+class JMXConsumerReconnectTest extends SimpleBeanFixture {
+
+    @RegisterExtension
+    AvailablePortFinder.Port portHolder = AvailablePortFinder.find();
+
+    /**
+     * How long no connection may be opened: three times the reconnect delay 
of one second
+     */
+    private static final int QUIET_SECONDS = 3;
+
+    private final Semaphore opened = new Semaphore(0);
+    private JMXServiceURL url;
+    private JMXConnectorServer connector;
+    private Registry registry;
+    private boolean beanRegistered;
+
+    @BeforeEach
+    @Override
+    public void setUp() throws Exception {
+        url = new JMXServiceURL("service:jmx:rmi:///jndi/rmi://localhost:" + 
portHolder.getPort() + "/" + DOMAIN);
+        registry = LocateRegistry.createRegistry(portHolder.getPort());
+        initServer();
+        initBean();
+        initContext();
+        startContext();
+    }
+
+    @Override
+    @AfterEach
+    public void tearDown() throws Exception {
+        if (!getContext().isStopped()) {
+            getContext().stop();
+        }
+        if (beanRegistered) {
+            unregisterBean(makeObjectName("simpleBean"));
+        }
+        if (connector != null) {
+            connector.stop();
+        }
+        if (registry != null) {
+            try {
+                UnicastRemoteObject.unexportObject(registry, true);
+            } catch (NoSuchObjectException e) {
+                // already unexported
+            }
+        }
+    }
+
+    @Override
+    protected void initServer() throws Exception {
+        server = MBeanServerFactory.newMBeanServer(DOMAIN);
+        connector = JMXConnectorServerFactory.newJMXConnectorServer(url, 
Collections.<String, Object> emptyMap(), server);
+        // one permit for every client connection that this connector server 
accepts
+        connector.addNotificationListener((notification, handback) -> {
+            if 
(JMXConnectionNotification.OPENED.equals(notification.getType())) {
+                opened.release();
+            }
+        }, null, null);
+        connector.start();
+    }
+
+    @Override
+    protected void initBean() throws Exception {
+        super.initBean();
+        beanRegistered = true;
+    }
+
+    @Override
+    protected JMXUriBuilder buildFromURI() {
+        return 
super.buildFromURI().withServerName(url.toString()).withReconnectDelay(1)
+                .withReconnectOnConnectionFailure(true);
+    }
+
+    /**
+     * A lost connection: the connector reports it with "failed" and then, 
when it closes itself, with "closed", both
+     * with the id of that connection (observed with the RMI connector of JDK 
21 when the server can no longer be
+     * reached). That is one lost connection, so the consumer must connect 
once.
+     */
+    @Test
+    void testReconnectsOnceWhenTheConnectionFailed() throws Exception {
+        MockEndpoint mock = getMockFixture().getMockEndpoint();
+        awaitFirstConnection(mock);
+
+        String connectionId = connector.getConnectionIds()[0];
+        NotificationListener listener = connectionListener();
+        listener.handleNotification(new JMXConnectionNotification(
+                JMXConnectionNotification.FAILED, connector, connectionId, 1, 
"Failed to communicate with the server",
+                null), null);
+        listener.handleNotification(new JMXConnectionNotification(
+                JMXConnectionNotification.CLOSED, connector, connectionId, 2, 
"Client has been closed", null), null);
+
+        assertTrue(opened.tryAcquire(20, TimeUnit.SECONDS), "the consumer did 
not replace its connection");
+
+        assertKeepsConnectionAndIsSubscribedOnce(mock);
+    }
+
+    /**
+     * The server restarts and the MBean is registered a little later than the 
connector is back (the usual order when
+     * an application restarts). Whether the connector re-establishes the 
connection itself or reports it as lost
+     * depends on timing; the consumer must keep trying to subscribe either 
way, and keep the connection it subscribed
+     * on.
+     */
+    @Test
+    void testSubscribesWhenTheBeanIsRegisteredAfterTheServerIsBack() throws 
Exception {
+        MockEndpoint mock = getMockFixture().getMockEndpoint();
+        awaitFirstConnection(mock);
+
+        // the server goes away and comes back without the MBean
+        connector.stop();
+        beanRegistered = false;
+        initServer();
+
+        // two connections: a connection without a subscription is not the 
end, the consumer connects again
+        assertTrue(opened.tryAcquire(2, 30, TimeUnit.SECONDS),
+                "the consumer did not keep trying to subscribe after the 
server came back without the MBean");
+        assertFalse(server.isRegistered(makeObjectName("simpleBean")));
+
+        // now the MBean is there: one of the next attempts subscribes
+        mock.reset();
+        mock.setMinimumExpectedMessageCount(1);
+        initBean();
+        awaitSubscribed(mock);
+
+        // The consumer is subscribed, so it has no reason to connect again. 
The check does not depend on the moment
+        // at which it starts: it tolerates a connection that is being opened 
right now and fails only when the
+        // consumer keeps connecting.
+        assertTrue(awaitNoConnectionFor(QUIET_SECONDS, 3),
+                "the consumer kept connecting although it was subscribed on a 
working connection");
+
+        assertDeliversOnce(mock);
+    }
+
+    /**
+     * The consumer replaces its connection when the server reports lost 
notifications. It closes the old connection
+     * itself, which must not count as one more lost connection.
+     */
+    @Test
+    void testReconnectsOnceWhenNotificationsWereLost() throws Exception {
+        MockEndpoint mock = getMockFixture().getMockEndpoint();
+        awaitFirstConnection(mock);
+
+        String connectionId = connector.getConnectionIds()[0];
+        connectionListener().handleNotification(new JMXConnectionNotification(
+                JMXConnectionNotification.NOTIFS_LOST, connector, 
connectionId, 1, "lost notifications", 1L), null);
+
+        assertTrue(opened.tryAcquire(20, TimeUnit.SECONDS), "the consumer did 
not replace its connection");
+
+        assertKeepsConnectionAndIsSubscribedOnce(mock);
+    }
+
+    /**
+     * The connector reports that it has opened a new connection by itself (as 
the RMI connector does after an I/O error
+     * when the server can be reached). The consumer cannot know whether its 
listener was registered again, so it
+     * subscribes again on a connection of its own.
+     */
+    @Test
+    void testSubscribesAgainWhenTheConnectorReconnectedByItself() throws 
Exception {
+        MockEndpoint mock = getMockFixture().getMockEndpoint();
+        awaitFirstConnection(mock);
+
+        connectionListener().handleNotification(new JMXConnectionNotification(
+                JMXConnectionNotification.OPENED, connector, 
"rmi://reconnected 2", 1, "Reconnected to server", null),
+                null);
+
+        assertTrue(opened.tryAcquire(20, TimeUnit.SECONDS),
+                "the consumer did not subscribe again after the connector had 
reconnected by itself");
+
+        assertKeepsConnectionAndIsSubscribedOnce(mock);
+    }
+
+    /**
+     * A notification about a lost connection that arrives after the consumer 
was stopped must not make it connect
+     * again.
+     */
+    @Test
+    void testDoesNotReconnectAfterStop() throws Exception {
+        MockEndpoint mock = getMockFixture().getMockEndpoint();
+        awaitFirstConnection(mock);
+
+        String connectionId = connector.getConnectionIds()[0];
+        NotificationListener listener = connectionListener();
+        Route route = getContext().getRoutes().get(0);
+        getContext().getRouteController().stopRoute(route.getRouteId());
+
+        listener.handleNotification(new JMXConnectionNotification(
+                JMXConnectionNotification.FAILED, connector, connectionId, 1, 
"Failed to communicate with the server",
+                null), null);
+
+        assertFalse(opened.tryAcquire(QUIET_SECONDS, TimeUnit.SECONDS),
+                "the consumer connected again although it was stopped");
+    }
+
+    private NotificationListener connectionListener() {
+        JMXConsumer consumer = (JMXConsumer) 
getContext().getRoutes().get(0).getConsumer();
+        return consumer.getConnectionNotificationListener();
+    }
+
+    private void awaitFirstConnection(MockEndpoint mock) throws Exception {
+        assertTrue(opened.tryAcquire(20, TimeUnit.SECONDS), "the consumer did 
not connect at start");
+        getSimpleMXBean().touch();
+        mock.assertIsSatisfied(20000);
+    }
+
+    /**
+     * The consumer is subscribed on a connection that works: it must not 
connect again (waits three times the reconnect
+     * delay of one second for a connection that must not come), and one 
notification is delivered once.
+     */
+    private void assertKeepsConnectionAndIsSubscribedOnce(MockEndpoint mock) 
throws Exception {
+        assertFalse(opened.tryAcquire(QUIET_SECONDS, TimeUnit.SECONDS),
+                "the consumer connected again although it was subscribed on a 
working connection");
+
+        assertDeliversOnce(mock);
+    }
+
+    private void assertDeliversOnce(MockEndpoint mock) throws Exception {
+        mock.reset();
+        mock.setExpectedMessageCount(1);
+        getSimpleMXBean().touch();
+        mock.assertIsSatisfied(20000);
+        assertEquals(1, mock.getReceivedCounter());

Review Comment:
   Right, done in 50ecdcec4f32 as suggested: `setAssertPeriod(1000)` after 
`reset()`, and the `getReceivedCounter()` check is
   gone (the period covers it).
   
   _Claude Code on behalf of allthingssecurity_



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to