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

davsclaus pushed a commit to branch fix/CAMEL-25876
in repository https://gitbox.apache.org/repos/asf/camel.git

commit 9c84f06bf1bfea6583d87c53248d7dd114ace765
Author: Claus Ibsen <[email protected]>
AuthorDate: Tue Oct 20 11:22:05 2026 +0200

    CAMEL-25876: camel-main - log why Camel Main failed to start, once, instead 
of only the JVM printing it on stderr
    
    A failure to start leaves MainSupport.run() to the caller, and the main 
class of an application (camel export, the
    archetypes) does not catch it, so only the JVM printed it, on stderr: the 
log of the application ended before the
    failure. KameletMain logged it already.
    
    - MainSupport.logStartupFailure logs "Error starting Camel: ..." with the 
stack trace; Main (and so the fat-jar Main of
      camel export) and KameletMain call it from doFail. Not in the base class: 
Camel Quarkus extends
      MainCommandLineSupport and logs the failure itself.
    - As Spring Boot does, the thread that started Camel then gets an uncaught 
exception handler that skips the logged
      failure, also when it is wrapped (the launcher of a fat jar wraps it in 
an InvocationTargetException), so the JVM does
      not print it a second time. Any other exception is printed as before; 
only when ERROR is logged.
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
    Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m
---
 .../apache/camel/main/LoggedStartupFailure.java    |  70 ++++++++++++
 .../src/main/java/org/apache/camel/main/Main.java  |   7 ++
 .../java/org/apache/camel/main/MainSupport.java    |  15 +++
 .../camel/main/LoggedStartupFailureTest.java       |  91 ++++++++++++++++
 .../apache/camel/main/MainStartupFailureTest.java  | 120 +++++++++++++++++++++
 .../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc    |   8 ++
 .../java/org/apache/camel/main/KameletMain.java    |   2 +-
 7 files changed, 312 insertions(+), 1 deletion(-)

diff --git 
a/core/camel-main/src/main/java/org/apache/camel/main/LoggedStartupFailure.java 
b/core/camel-main/src/main/java/org/apache/camel/main/LoggedStartupFailure.java
new file mode 100644
index 000000000000..3058f5c4f9f5
--- /dev/null
+++ 
b/core/camel-main/src/main/java/org/apache/camel/main/LoggedStartupFailure.java
@@ -0,0 +1,70 @@
+/*
+ * 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.main;
+
+/**
+ * The uncaught exception handler of the thread that started Camel, once Camel 
failed to start and the failure was
+ * logged: the JVM does not print that failure a second time when it leaves 
main, also not when a launcher wraps it (the
+ * launcher of a fat jar calls main by reflection, and wraps the failure in an 
InvocationTargetException). Any other
+ * exception goes to the handler the thread had before, which prints it as the 
JVM does.
+ * <p/>
+ * Spring Boot does the same for an application that fails to start.
+ */
+final class LoggedStartupFailure implements Thread.UncaughtExceptionHandler {
+
+    private static final int MAX_CAUSES = 32;
+
+    private final Thread.UncaughtExceptionHandler before;
+    private final Throwable logged;
+
+    private LoggedStartupFailure(Thread.UncaughtExceptionHandler before, 
Throwable logged) {
+        this.before = before;
+        this.logged = logged;
+    }
+
+    /**
+     * The failure was logged: the current thread does not print it again when 
the failure leaves the thread.
+     */
+    static void skipWhenUncaught(Throwable logged) {
+        Thread thread = Thread.currentThread();
+        // the handler of the thread, else its thread group, which prints an 
exception as the JVM does
+        Thread.UncaughtExceptionHandler before = 
thread.getUncaughtExceptionHandler();
+        if (before instanceof LoggedStartupFailure earlier) {
+            before = earlier.before;
+        }
+        thread.setUncaughtExceptionHandler(new LoggedStartupFailure(before, 
logged));
+    }
+
+    @Override
+    public void uncaughtException(Thread thread, Throwable e) {
+        if (!isLogged(e)) {
+            before.uncaughtException(thread, e);
+        }
+    }
+
+    // the logged failure, or an exception that wraps it
+    boolean isLogged(Throwable e) {
+        Throwable t = e;
+        for (int i = 0; t != null && i < MAX_CAUSES; i++) {
+            if (t == logged) {
+                return true;
+            }
+            t = t.getCause();
+        }
+        return false;
+    }
+}
diff --git a/core/camel-main/src/main/java/org/apache/camel/main/Main.java 
b/core/camel-main/src/main/java/org/apache/camel/main/Main.java
index 9dbc1507a872..6979f31b2d21 100644
--- a/core/camel-main/src/main/java/org/apache/camel/main/Main.java
+++ b/core/camel-main/src/main/java/org/apache/camel/main/Main.java
@@ -41,6 +41,13 @@ public class Main extends MainCommandLineSupport {
     public Main() {
     }
 
+    @Override
+    protected void doFail(Exception e) {
+        // a main that runs on its own: logged, as otherwise only the JVM 
prints the failure, on stderr
+        logStartupFailure(e);
+        super.doFail(e);
+    }
+
     /**
      * Camel main application
      *
diff --git 
a/core/camel-main/src/main/java/org/apache/camel/main/MainSupport.java 
b/core/camel-main/src/main/java/org/apache/camel/main/MainSupport.java
index 9f2d109a0fa9..92fdc508ce68 100644
--- a/core/camel-main/src/main/java/org/apache/camel/main/MainSupport.java
+++ b/core/camel-main/src/main/java/org/apache/camel/main/MainSupport.java
@@ -107,6 +107,21 @@ public abstract class MainSupport extends BaseMainSupport {
         }
     }
 
+    /**
+     * Logs that Camel failed to start, for a main that runs on its own: the 
failure leaves {@link #run()} to the
+     * caller, and without this only the JVM prints it, on stderr, so the log 
of the application did not say why it
+     * stopped. As Spring Boot does, the JVM then does not print the failure a 
second time when it leaves main; only
+     * when it was logged, so an application without logging still sees it.
+     *
+     * @param e the failure
+     */
+    protected void logStartupFailure(Exception e) {
+        LOG.error("Error starting Camel: {}", e, e);
+        if (LOG.isErrorEnabled()) {
+            LoggedStartupFailure.skipWhenUncaught(e);
+        }
+    }
+
     /**
      * Callback to run custom logic before CamelContext is being started.
      * <p/>
diff --git 
a/core/camel-main/src/test/java/org/apache/camel/main/LoggedStartupFailureTest.java
 
b/core/camel-main/src/test/java/org/apache/camel/main/LoggedStartupFailureTest.java
new file mode 100644
index 000000000000..3f3f9d2d0034
--- /dev/null
+++ 
b/core/camel-main/src/test/java/org/apache/camel/main/LoggedStartupFailureTest.java
@@ -0,0 +1,91 @@
+/*
+ * 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.main;
+
+import java.lang.reflect.InvocationTargetException;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+import org.apache.camel.RuntimeCamelException;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.parallel.Isolated;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * A failure to start that was logged is not printed a second time by the JVM, 
also when it is wrapped.
+ */
+@Isolated
+class LoggedStartupFailureTest {
+
+    private final List<Throwable> printed = new CopyOnWriteArrayList<>();
+    private Thread.UncaughtExceptionHandler original;
+
+    @BeforeEach
+    void recordWhatTheThreadPrints() {
+        original = Thread.currentThread().getUncaughtExceptionHandler();
+        Thread.currentThread().setUncaughtExceptionHandler((t, e) -> 
printed.add(e));
+    }
+
+    @AfterEach
+    void restoreTheHandler() {
+        // the thread group is what the thread uses without a handler of its 
own
+        Thread.currentThread().setUncaughtExceptionHandler(original instanceof 
ThreadGroup ? null : original);
+    }
+
+    private void leavesTheThread(Throwable e) {
+        
Thread.currentThread().getUncaughtExceptionHandler().uncaughtException(Thread.currentThread(),
 e);
+    }
+
+    @Test
+    void theLoggedFailureIsNotPrintedAgain() {
+        Exception failure = new IllegalStateException("cannot start");
+        LoggedStartupFailure.skipWhenUncaught(failure);
+
+        leavesTheThread(failure);
+        // wrapped as BaseService.doFail does, and as the launcher of a fat 
jar does, which calls main by reflection
+        leavesTheThread(new RuntimeCamelException(failure));
+        leavesTheThread(new InvocationTargetException(new 
RuntimeCamelException(failure)));
+
+        assertThat(printed).isEmpty();
+    }
+
+    @Test
+    void anotherExceptionIsPrintedAsBefore() {
+        LoggedStartupFailure.skipWhenUncaught(new 
IllegalStateException("cannot start"));
+        IllegalArgumentException other = new 
IllegalArgumentException("another");
+
+        leavesTheThread(other);
+
+        assertThat(printed).containsExactly(other);
+    }
+
+    @Test
+    void aLaterFailureReplacesTheOneBefore() {
+        Exception first = new IllegalStateException("first");
+        Exception second = new IllegalStateException("second");
+        LoggedStartupFailure.skipWhenUncaught(first);
+        LoggedStartupFailure.skipWhenUncaught(second);
+
+        leavesTheThread(first);
+        leavesTheThread(second);
+
+        assertThat(printed).containsExactly(first);
+    }
+}
diff --git 
a/core/camel-main/src/test/java/org/apache/camel/main/MainStartupFailureTest.java
 
b/core/camel-main/src/test/java/org/apache/camel/main/MainStartupFailureTest.java
new file mode 100644
index 000000000000..d4545404f2e8
--- /dev/null
+++ 
b/core/camel-main/src/test/java/org/apache/camel/main/MainStartupFailureTest.java
@@ -0,0 +1,120 @@
+/*
+ * 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.main;
+
+import java.lang.reflect.InvocationTargetException;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.logging.log4j.Level;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.core.LogEvent;
+import org.apache.logging.log4j.core.LoggerContext;
+import org.apache.logging.log4j.core.appender.AbstractAppender;
+import org.apache.logging.log4j.core.config.Configuration;
+import org.apache.logging.log4j.core.config.LoggerConfig;
+import org.apache.logging.log4j.core.config.Property;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.parallel.Isolated;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/**
+ * A Camel Main that fails to start logs why, as the failure leaves run() to 
the caller and was otherwise only printed
+ * by the JVM, on stderr; the JVM then does not print it a second time.
+ */
+@Isolated
+class MainStartupFailureTest {
+
+    private static final String LOGGER = MainSupport.class.getName();
+
+    private final List<String> errors = new CopyOnWriteArrayList<>();
+    private final List<Throwable> printed = new CopyOnWriteArrayList<>();
+    private AbstractAppender appender;
+    private Thread.UncaughtExceptionHandler original;
+
+    @BeforeEach
+    void setUp() {
+        appender = new AbstractAppender("MainStartupFailureTest", null, null, 
true, Property.EMPTY_ARRAY) {
+            @Override
+            public void append(LogEvent event) {
+                if (event.getLevel() == Level.ERROR) {
+                    errors.add(event.getMessage().getFormattedMessage());
+                }
+            }
+        };
+        appender.start();
+        LoggerContext ctx = (LoggerContext) LogManager.getContext(false);
+        Configuration config = ctx.getConfiguration();
+        LoggerConfig loggerConfig = new LoggerConfig(LOGGER, Level.ERROR, 
true);
+        loggerConfig.addAppender(appender, Level.ERROR, null);
+        config.addLogger(LOGGER, loggerConfig);
+        ctx.updateLoggers();
+
+        original = Thread.currentThread().getUncaughtExceptionHandler();
+        Thread.currentThread().setUncaughtExceptionHandler((t, e) -> 
printed.add(e));
+    }
+
+    @AfterEach
+    void tearDown() {
+        Thread.currentThread().setUncaughtExceptionHandler(original instanceof 
ThreadGroup ? null : original);
+        LoggerContext ctx = (LoggerContext) LogManager.getContext(false);
+        ctx.getConfiguration().removeLogger(LOGGER);
+        ctx.updateLoggers();
+        appender.stop();
+    }
+
+    @Test
+    void aFailureToStartIsLoggedAndNotPrintedAgain() {
+        Main main = new Main();
+        main.configure().addRoutesBuilder(new RouteBuilder() {
+            @Override
+            public void configure() {
+                throw new IllegalStateException("cannot configure the routes");
+            }
+        });
+
+        Exception e = assertThrows(Exception.class, main::run);
+
+        assertThat(errors).singleElement().satisfies(m -> 
assertThat(m).startsWith("Error starting Camel: ")
+                .contains("cannot configure the routes"));
+        // the failure leaves main, as the launcher of a fat jar wraps it: the 
JVM does not print it again
+        Thread.currentThread().getUncaughtExceptionHandler()
+                .uncaughtException(Thread.currentThread(), new 
InvocationTargetException(e));
+        assertThat(printed).isEmpty();
+    }
+
+    @Test
+    void anotherExceptionIsStillPrinted() {
+        Main main = new Main();
+        main.configure().addRoutesBuilder(new RouteBuilder() {
+            @Override
+            public void configure() {
+                throw new IllegalStateException("cannot configure the routes");
+            }
+        });
+        assertThrows(Exception.class, main::run);
+
+        IllegalArgumentException other = new 
IllegalArgumentException("another");
+        
Thread.currentThread().getUncaughtExceptionHandler().uncaughtException(Thread.currentThread(),
 other);
+        assertThat(printed).containsExactly(other);
+    }
+}
diff --git 
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
index 33070e296cf3..26a3d10cc1de 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
@@ -30,6 +30,14 @@ in its model, so clearing them on the route saved little 
memory at the cost of m
 Applications that called `Route.clearRouteModel()` directly can remove the 
call. Implementations of the `Route` interface
 that override `clearRouteModel()` can remove the override.
 
+=== camel-main - a failure to start is logged
+
+A Camel Main application that fails to start now logs why, at ERROR: `Error 
starting Camel: ...` with the stack trace,
+as `camel run` already did. Before, the failure left `main()` and only the JVM 
printed it, on stderr
+(`Exception in thread "main" ...`), so the log file of the application did not 
say why it stopped.
+The JVM no longer prints that same failure a second time. When the application 
logs nothing at ERROR (such as without
+a logging implementation), the JVM prints it as before.
+
 === camel-report-maven-plugin:validate / configuration-validate
 
 The catalog now accepts all `camel.*` groups defined in 
`camel-main-configuration-metadata.json`
diff --git 
a/dsl/camel-kamelet-main/src/main/java/org/apache/camel/main/KameletMain.java 
b/dsl/camel-kamelet-main/src/main/java/org/apache/camel/main/KameletMain.java
index e65becbd5562..5883c84b09e4 100644
--- 
a/dsl/camel-kamelet-main/src/main/java/org/apache/camel/main/KameletMain.java
+++ 
b/dsl/camel-kamelet-main/src/main/java/org/apache/camel/main/KameletMain.java
@@ -198,7 +198,7 @@ public class KameletMain extends MainCommandLineSupport {
             }
         }
         // ensure any unhandled fatal errors are also logged before 
terminating process
-        LOG.error("Error starting Camel: {}", e, e);
+        logStartupFailure(e);
         super.doFail(e);
     }
 

Reply via email to