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); }
