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 d36daaec92cc3c75e1eb25d06bb5e038bdfec0d7
Author: croway <[email protected]>
AuthorDate: Fri Jul 10 18:13:39 2026 +0200

    CAMEL-24000: camel-spring-boot - notify MainListener beans exactly once 
with run-controller enabled
    
    With camel.main.run-controller=true, MainListener beans were notified twice
    for beforeStart/afterStart (and beforeStop/afterStop): once via the
    controller created by CamelAutoConfiguration (the complete callback stream
    added in CAMEL-21359) and once more via the run controller's internal Main,
    which registered the same listener beans and fires the start/stop callbacks
    from MainSupport.run().
    
    The run controller's Main is only used to keep the JVM running, so it no
    longer registers the MainListener beans.
    
    Co-Authored-By: Claude Fable 5 <[email protected]>
---
 .../camel/spring/boot/CamelMainRunController.java  |  14 +-
 .../boot/CamelSpringBootApplicationController.java |  21 ++-
 .../boot/CamelMainListenerRunControllerTest.java   | 155 +++++++++++++++++++++
 3 files changed, 176 insertions(+), 14 deletions(-)

diff --git 
a/core/camel-spring-boot/src/main/java/org/apache/camel/spring/boot/CamelMainRunController.java
 
b/core/camel-spring-boot/src/main/java/org/apache/camel/spring/boot/CamelMainRunController.java
index e146adfd2d0..4a4e7a63ef8 100644
--- 
a/core/camel-spring-boot/src/main/java/org/apache/camel/spring/boot/CamelMainRunController.java
+++ 
b/core/camel-spring-boot/src/main/java/org/apache/camel/spring/boot/CamelMainRunController.java
@@ -17,12 +17,9 @@
 package org.apache.camel.spring.boot;
 
 import org.apache.camel.CamelContext;
-import org.apache.camel.main.MainListener;
 import org.apache.camel.main.MainShutdownStrategy;
 import org.springframework.context.ApplicationContext;
 
-import java.util.Map;
-
 /**
  * Controller to keep the main running and perform graceful shutdown when the 
JVM is stopped.
  */
@@ -32,15 +29,12 @@ public class CamelMainRunController {
     private final Thread daemon;
 
     public CamelMainRunController(ApplicationContext applicationContext, 
CamelContext camelContext) {
-        controller = new 
CamelSpringBootApplicationController(applicationContext);
+        // do not register the MainListener beans on this controller: they are 
already notified
+        // via the controller created by CamelAutoConfiguration, and this Main 
is only used
+        // to keep the JVM running
+        controller = new 
CamelSpringBootApplicationController(applicationContext, false);
         controller.setCamelContext(camelContext);
 
-        // setup main listeners eager on controller
-        final Map<String, MainListener> listeners = 
applicationContext.getBeansOfType(MainListener.class);
-        for (MainListener listener : listeners.values()) {
-            controller.getMain().addMainListener(listener);
-        }
-
         daemon = new Thread(new DaemonTask(), "CamelMainRunController");
     }
 
diff --git 
a/core/camel-spring-boot/src/main/java/org/apache/camel/spring/boot/CamelSpringBootApplicationController.java
 
b/core/camel-spring-boot/src/main/java/org/apache/camel/spring/boot/CamelSpringBootApplicationController.java
index 859f332fb61..b157f48164e 100644
--- 
a/core/camel-spring-boot/src/main/java/org/apache/camel/spring/boot/CamelSpringBootApplicationController.java
+++ 
b/core/camel-spring-boot/src/main/java/org/apache/camel/spring/boot/CamelSpringBootApplicationController.java
@@ -37,11 +37,24 @@ public class CamelSpringBootApplicationController 
implements CamelContextAware {
     private CamelContext camelContext;
 
     public CamelSpringBootApplicationController(final ApplicationContext 
applicationContext) {
+        this(applicationContext, true);
+    }
+
+    /**
+     * @param registerMainListeners whether to register the {@link 
MainListener} beans from the application context on
+     *                              the internal {@link Main}. The controller 
used by the main run controller must not
+     *                              register them, as the listeners are 
already notified via the controller created by
+     *                              {@link CamelAutoConfiguration}, and 
registering them on both would notify each
+     *                              listener twice.
+     */
+    CamelSpringBootApplicationController(final ApplicationContext 
applicationContext, boolean registerMainListeners) {
         this.main = new CamelSpringMain(applicationContext, this);
-        // inject main listeners
-        final Map<String, MainListener> listeners = 
applicationContext.getBeansOfType(MainListener.class);
-        for (MainListener listener : listeners.values()) {
-            this.main.addMainListener(listener);
+        if (registerMainListeners) {
+            // inject main listeners
+            final Map<String, MainListener> listeners = 
applicationContext.getBeansOfType(MainListener.class);
+            for (MainListener listener : listeners.values()) {
+                this.main.addMainListener(listener);
+            }
         }
     }
 
diff --git 
a/core/camel-spring-boot/src/test/java/org/apache/camel/spring/boot/CamelMainListenerRunControllerTest.java
 
b/core/camel-spring-boot/src/test/java/org/apache/camel/spring/boot/CamelMainListenerRunControllerTest.java
new file mode 100644
index 00000000000..c7d377e5783
--- /dev/null
+++ 
b/core/camel-spring-boot/src/test/java/org/apache/camel/spring/boot/CamelMainListenerRunControllerTest.java
@@ -0,0 +1,155 @@
+/*
+ * 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.spring.boot;
+
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.main.BaseMainSupport;
+import org.apache.camel.main.MainListener;
+import org.apache.camel.main.MainListenerSupport;
+import org.apache.camel.test.spring.junit6.CamelSpringBootTest;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.test.annotation.DirtiesContext;
+
+import static org.awaitility.Awaitility.await;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Verifies that a {@link MainListener} bean receives each lifecycle callback 
exactly once when the main run
+ * controller is enabled (camel.main.run-controller=true).
+ */
+@DirtiesContext
+@CamelSpringBootTest
+@EnableAutoConfiguration
+@SpringBootTest(properties = "camel.main.run-controller=true")
+public class CamelMainListenerRunControllerTest {
+
+    @Configuration
+    static class Config {
+
+        @Bean
+        RouteBuilder routeBuilder() {
+            return new RouteBuilder() {
+                @Override
+                public void configure() {
+                    from("direct:start").to("mock:result");
+                }
+            };
+        }
+
+        @Bean
+        CountingMainListener countingMainListener() {
+            return new CountingMainListener();
+        }
+
+    }
+
+    @Autowired
+    CamelContext camelContext;
+
+    @Autowired
+    CountingMainListener listener;
+
+    @Test
+    public void testMainListenerCallbacksFireExactlyOnce() {
+        // the run controller notifies listeners from a background daemon 
thread,
+        // so wait until the start phase has completed
+        await().atMost(20, TimeUnit.SECONDS)
+                .untilAsserted(() -> assertTrue(listener.count("afterStart") 
>= 1,
+                        "afterStart should have been invoked"));
+
+        // the counts must remain exactly 1 (hold for a while to catch late 
duplicate notifications
+        // from the run controller background thread)
+        await().during(2, TimeUnit.SECONDS).atMost(10, TimeUnit.SECONDS)
+                .untilAsserted(() -> {
+                    assertEquals(1, listener.count("beforeInitialize"), () -> 
"beforeInitialize: " + listener.counts);
+                    assertEquals(1, listener.count("beforeConfigure"), () -> 
"beforeConfigure: " + listener.counts);
+                    assertEquals(1, listener.count("afterConfigure"), () -> 
"afterConfigure: " + listener.counts);
+                    assertEquals(1, listener.count("beforeStart"), () -> 
"beforeStart: " + listener.counts);
+                    assertEquals(1, listener.count("afterStart"), () -> 
"afterStart: " + listener.counts);
+                });
+
+        camelContext.stop();
+
+        await().during(2, TimeUnit.SECONDS).atMost(10, TimeUnit.SECONDS)
+                .untilAsserted(() -> {
+                    assertEquals(1, listener.count("beforeStop"), () -> 
"beforeStop: " + listener.counts);
+                    assertEquals(1, listener.count("afterStop"), () -> 
"afterStop: " + listener.counts);
+                });
+    }
+
+    public static class CountingMainListener extends MainListenerSupport {
+
+        private final Map<String, AtomicInteger> counts = new 
ConcurrentHashMap<>();
+
+        int count(String callback) {
+            AtomicInteger counter = counts.get(callback);
+            return counter != null ? counter.get() : 0;
+        }
+
+        private void record(String callback) {
+            counts.computeIfAbsent(callback, k -> new 
AtomicInteger()).incrementAndGet();
+        }
+
+        @Override
+        public void beforeInitialize(BaseMainSupport main) {
+            record("beforeInitialize");
+        }
+
+        @Override
+        public void beforeConfigure(BaseMainSupport main) {
+            record("beforeConfigure");
+        }
+
+        @Override
+        public void afterConfigure(BaseMainSupport main) {
+            record("afterConfigure");
+        }
+
+        @Override
+        public void beforeStart(BaseMainSupport main) {
+            record("beforeStart");
+        }
+
+        @Override
+        public void afterStart(BaseMainSupport main) {
+            record("afterStart");
+        }
+
+        @Override
+        public void beforeStop(BaseMainSupport main) {
+            record("beforeStop");
+        }
+
+        @Override
+        public void afterStop(BaseMainSupport main) {
+            record("afterStop");
+        }
+    }
+
+}

Reply via email to