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 c7d3889e053b CAMEL-25400: camel-jbang - One helper for child processes
and the Maven wrapper (#27933)
c7d3889e053b is described below
commit c7d3889e053b22d8faf75e0cff12d953b1293564
Author: Claus Ibsen <[email protected]>
AuthorDate: Fri Oct 16 08:41:20 2026 +0200
CAMEL-25400: camel-jbang - One helper for child processes and the Maven
wrapper (#27933)
- ChildProcess.stopOnExit replaces the 8 copies of the shutdown hook that
stops the Maven process of camel run (Spring Boot, Quarkus, an existing
project) and camel debug, and then removes the files of the run. It
stops the processes Maven started too (the app runs in a JVM of its own,
which could outlive Maven), and kills what has not stopped after 30
seconds (the hooks waited, and left a process that did not stop).
- MavenCommand replaces the 9 lookups of the Maven wrapper (mvnw, or
mvnw.cmd on Windows, else mvn) in camel run, camel debug and camel
kubernetes run.
- A plugin that cannot be loaded (not found, or too new for the Camel
version) ends the command with exit code 1 and the error, instead of
System.exit in the middle of the plugin loading, which ended a JVM that
embeds the CLI.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
---
.../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc | 8 +
.../dsl/jbang/core/commands/CamelJBangMain.java | 12 +-
.../camel/dsl/jbang/core/commands/Debug.java | 78 ++------
.../apache/camel/dsl/jbang/core/commands/Run.java | 218 +++++----------------
.../camel/dsl/jbang/core/common/ChildProcess.java | 92 +++++++++
.../camel/dsl/jbang/core/common/MavenCommand.java | 46 +++++
.../camel/dsl/jbang/core/common/PluginHelper.java | 24 +--
.../dsl/jbang/core/common/ChildProcessTest.java | 80 ++++++++
.../dsl/jbang/core/common/PluginHelperTest.java | 25 +--
.../core/commands/kubernetes/KubernetesRun.java | 13 +-
10 files changed, 325 insertions(+), 271 deletions(-)
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 51bc0359f6c2..d560b88d9dda 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
@@ -446,6 +446,14 @@ or follows, like Enter does.
`--verbose`), and exits with `124` when the pod is not running after 10
minutes.
* `--jvm-args` keeps a quoted value with spaces as one argument.
+=== camel-jbang - processes started by the CLI stop with it
+
+* When `camel run` of a Spring Boot or Quarkus project (and `camel debug`)
ends, such as on Ctrl+C, the processes
+ started by Maven stop too: the app could outlive Maven, as it runs in a JVM
of its own. A process that does not stop
+ within 30 seconds is killed (it was left running).
+* A plugin that cannot be loaded ends the command with exit code `1` and the
error, as before, but no longer exits the
+ JVM while the plugins load, which ended a program that embeds the CLI.
+
=== camel-jpa, camel-hazelcast - an exchange marked rollback only is not
consumed
When a route marks the exchange rollback only (for example with
`markRollbackOnly()` or `markRollbackOnlyLast()`)
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/CamelJBangMain.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/CamelJBangMain.java
index 5838bb10451d..66143a1be5e6 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/CamelJBangMain.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/CamelJBangMain.java
@@ -63,6 +63,7 @@ import
org.apache.camel.dsl.jbang.core.commands.version.VersionCommand;
import org.apache.camel.dsl.jbang.core.commands.version.VersionGet;
import org.apache.camel.dsl.jbang.core.commands.version.VersionList;
import org.apache.camel.dsl.jbang.core.commands.version.VersionSet;
+import org.apache.camel.dsl.jbang.core.common.CliException;
import org.apache.camel.dsl.jbang.core.common.CliExitCode;
import org.apache.camel.dsl.jbang.core.common.CommandLineHelper;
import org.apache.camel.dsl.jbang.core.common.EnvironmentHelper;
@@ -259,8 +260,15 @@ public class CamelJBangMain implements Callable<Integer> {
postAddCommands(commandLine, args);
if (discoverPlugins && PluginHelper.shouldDiscoverPlugins(commandLine,
args)) {
- PluginHelper.addPlugins(commandLine, this, args);
- PluginHelper.installOnFirstUse(commandLine, this, args);
+ try {
+ PluginHelper.addPlugins(commandLine, this, args);
+ PluginHelper.installOnFirstUse(commandLine, this, args);
+ } catch (CliException e) {
+ // a plugin that cannot be loaded
+ out.printErr(e.getMessage());
+ quit(e.getExitCode());
+ return;
+ }
}
withErrorHandling(commandLine);
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Debug.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Debug.java
index a15c7aa33557..5eaae7402c07 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Debug.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Debug.java
@@ -40,9 +40,11 @@ import java.util.stream.Collectors;
import org.apache.camel.dsl.jbang.core.commands.action.MessageTableHelper;
import org.apache.camel.dsl.jbang.core.common.CamelCommandHelper;
+import org.apache.camel.dsl.jbang.core.common.ChildProcess;
import org.apache.camel.dsl.jbang.core.common.CliExitCode;
import org.apache.camel.dsl.jbang.core.common.CommandLineHelper;
import org.apache.camel.dsl.jbang.core.common.EnvironmentHelper;
+import org.apache.camel.dsl.jbang.core.common.MavenCommand;
import org.apache.camel.dsl.jbang.core.common.PathUtils;
import org.apache.camel.dsl.jbang.core.common.ProcessFinder;
import org.apache.camel.dsl.jbang.core.common.VersionHelper;
@@ -387,13 +389,7 @@ public class Debug extends Run {
printer().println("Preparing Camel Spring Boot for debugging ...");
// use maven wrapper if present
- String mvnw = "mvnw";
- if (FileUtil.isWindows()) {
- mvnw = "mvnw.cmd";
- }
- if (!new File(mvnw).exists()) {
- mvnw = "mvn";
- }
+ String mvnw = MavenCommand.of(Path.of("."));
// use maven to build the JAR and then run the JAR after-wards
ProcessBuilder pb = new ProcessBuilder();
pb.command(mvnw, "-Dmaven.test.skip", "--file",
"camel-jbang-debug-pom.xml", "package", "spring-boot:repackage");
@@ -407,30 +403,12 @@ public class Debug extends Run {
}
if (built) {
AtomicReference<Process> processRef = new AtomicReference<>();
- Runtime.getRuntime().addShutdownHook(new Thread(() -> {
- try {
- // We need to wait for the process to exit before
doing any cleanup
- Process process = processRef.get();
- if (process != null) {
- process.destroy();
- for (int i = 0; i < 30; i++) {
- if (!process.isAlive()) {
- break;
- }
- try {
- Thread.sleep(1000);
- } catch (InterruptedException e) {
- Thread.currentThread().interrupt();
- }
- }
- }
- removeDir(Paths.get(RUN_PLATFORM_DIR));
- removeDir(Paths.get(CAMEL_JBANG_WORK_DIR));
-
Files.deleteIfExists(Paths.get("camel-jbang-debug-pom.xml"));
- } catch (Exception e) {
- // Ignore
- }
- }));
+ // the process (and those it started) stops with this JVM,
then the files of the run are removed
+ ChildProcess.stopOnExit(processRef::get, () -> {
+ removeDir(Paths.get(RUN_PLATFORM_DIR));
+ removeDir(Paths.get(CAMEL_JBANG_WORK_DIR));
+
Files.deleteIfExists(Paths.get("camel-jbang-debug-pom.xml"));
+ });
// okay build is complete then run java
pb = new ProcessBuilder();
@@ -518,13 +496,7 @@ public class Debug extends Run {
printer().println("Preparing Camel Quarkus for debugging ...");
// use maven wrapper if present
- String mvnw = "mvnw";
- if (FileUtil.isWindows()) {
- mvnw = "mvnw.cmd";
- }
- if (!new File(mvnw).exists()) {
- mvnw = "mvn";
- }
+ String mvnw = MavenCommand.of(Path.of("."));
// use maven to build the JAR and then run the JAR after-wards
ProcessBuilder pb = new ProcessBuilder();
pb.command(mvnw, "-Dmaven.test.skip", "--file",
"camel-jbang-debug-pom.xml", "package", "quarkus:build");
@@ -538,30 +510,12 @@ public class Debug extends Run {
}
if (built) {
AtomicReference<Process> processRef = new AtomicReference<>();
- Runtime.getRuntime().addShutdownHook(new Thread(() -> {
- try {
- // We need to wait for the process to exit before
doing any cleanup
- Process process = processRef.get();
- if (process != null) {
- process.destroy();
- for (int i = 0; i < 30; i++) {
- if (!process.isAlive()) {
- break;
- }
- try {
- Thread.sleep(1000);
- } catch (InterruptedException e) {
- Thread.currentThread().interrupt();
- }
- }
- }
- removeDir(Paths.get(RUN_PLATFORM_DIR));
- removeDir(Paths.get(CAMEL_JBANG_WORK_DIR));
-
Files.deleteIfExists(Paths.get("camel-jbang-debug-pom.xml"));
- } catch (Exception e) {
- // Ignore
- }
- }));
+ // the process (and those it started) stops with this JVM,
then the files of the run are removed
+ ChildProcess.stopOnExit(processRef::get, () -> {
+ removeDir(Paths.get(RUN_PLATFORM_DIR));
+ removeDir(Paths.get(CAMEL_JBANG_WORK_DIR));
+
Files.deleteIfExists(Paths.get("camel-jbang-debug-pom.xml"));
+ });
// okay build is complete then run java
pb = new ProcessBuilder();
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Run.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Run.java
index 0a4f08332fdb..28a911053fe4 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Run.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Run.java
@@ -51,6 +51,7 @@ import org.apache.camel.catalog.CamelCatalog;
import org.apache.camel.catalog.DefaultCamelCatalog;
import org.apache.camel.dsl.jbang.core.commands.ai.YamlLoadFailureReport;
import org.apache.camel.dsl.jbang.core.common.BackgroundLog;
+import org.apache.camel.dsl.jbang.core.common.ChildProcess;
import org.apache.camel.dsl.jbang.core.common.CliException;
import org.apache.camel.dsl.jbang.core.common.CliExitCode;
import org.apache.camel.dsl.jbang.core.common.CommandLineHelper;
@@ -60,6 +61,7 @@ import
org.apache.camel.dsl.jbang.core.common.GenAiDependencyHelper;
import org.apache.camel.dsl.jbang.core.common.JavaVersionCompletionCandidates;
import org.apache.camel.dsl.jbang.core.common.LauncherHelper;
import org.apache.camel.dsl.jbang.core.common.LoggingLevelCompletionCandidates;
+import org.apache.camel.dsl.jbang.core.common.MavenCommand;
import org.apache.camel.dsl.jbang.core.common.Plugin;
import org.apache.camel.dsl.jbang.core.common.PluginExporter;
import org.apache.camel.dsl.jbang.core.common.PluginHelper;
@@ -1699,36 +1701,15 @@ public class Run extends CamelCommand {
Path runDirPath = Paths.get(RUN_PLATFORM_DIR,
Long.toString(System.currentTimeMillis()));
if (!this.background) {
// Mark for deletion on exit
- Runtime.getRuntime().addShutdownHook(new Thread(() -> {
- try {
- // We need to wait for the process to exit before doing
any cleanup
- Process process = processRef.get();
- if (process != null) {
- process.destroy();
-
- for (int i = 0; i < 30; i++) {
- if (!process.isAlive()) {
- break;
- }
-
- try {
- Thread.sleep(1000);
- } catch (InterruptedException e) {
- Thread.currentThread().interrupt();
- }
- }
- }
-
- removeDir(runDirPath);
- // cleanup log file
- String appName = appNameRef.get();
- if (appName != null) {
-
Files.deleteIfExists(CommandLineHelper.getCamelDir().resolve(appName + ".log"));
- }
- } catch (Exception e) {
- // Ignore
+ // the process (and those it started) stops with this JVM, then
the files of the run are removed
+ ChildProcess.stopOnExit(processRef::get, () -> {
+ removeDir(runDirPath);
+ // cleanup log file
+ String appName = appNameRef.get();
+ if (appName != null) {
+
Files.deleteIfExists(CommandLineHelper.getCamelDir().resolve(appName + ".log"));
}
- }));
+ });
}
Files.createDirectories(runDirPath);
@@ -1804,13 +1785,9 @@ public class Run extends CamelCommand {
}
// run quarkus via maven
- String mvnw = "/mvnw";
- if (FileUtil.isWindows()) {
- mvnw = "/mvnw.cmd";
- }
ProcessBuilder pb = new ProcessBuilder();
List<String> mvnCmd = new ArrayList<>();
- mvnCmd.add(runDirPath + mvnw);
+ mvnCmd.add(MavenCommand.wrapper(runDirPath).toString());
mvnCmd.add("--quiet");
mvnCmd.add("--file");
mvnCmd.add(runDirPath.toRealPath().resolve("pom.xml").toString());
@@ -1884,36 +1861,15 @@ public class Run extends CamelCommand {
// create temp run dir
Path runDirPath = Paths.get(RUN_PLATFORM_DIR,
Long.toString(System.currentTimeMillis()));
// Mark for deletion on exit
- Runtime.getRuntime().addShutdownHook(new Thread(() -> {
- try {
- // We need to wait for the process to exit before doing any
cleanup
- Process process = processRef.get();
- if (process != null) {
- process.destroy();
-
- for (int i = 0; i < 30; i++) {
- if (!process.isAlive()) {
- break;
- }
-
- try {
- Thread.sleep(1000);
- } catch (InterruptedException e) {
- Thread.currentThread().interrupt();
- }
- }
- }
-
- removeDir(runDirPath);
- // cleanup log file
- String appName = appNameRef.get();
- if (appName != null) {
-
Files.deleteIfExists(CommandLineHelper.getCamelDir().resolve(appName + ".log"));
- }
- } catch (Exception e) {
- // Ignore
- }
- }));
+ // the process (and those it started) stops with this JVM, then the
files of the run are removed
+ ChildProcess.stopOnExit(processRef::get, () -> {
+ removeDir(runDirPath);
+ // cleanup log file
+ String appName = appNameRef.get();
+ if (appName != null) {
+
Files.deleteIfExists(CommandLineHelper.getCamelDir().resolve(appName + ".log"));
+ }
+ });
Files.createDirectories(runDirPath);
// export to hidden folder
@@ -1976,12 +1932,8 @@ public class Run extends CamelCommand {
writeCamelMainRunLogConfig(runDirPath, appName);
// package via maven (the runner JAR contains all dependencies)
- String mvnw = "/mvnw";
- if (FileUtil.isWindows()) {
- mvnw = "/mvnw.cmd";
- }
List<String> mvnCmd = new ArrayList<>();
- mvnCmd.add(runDirPath + mvnw);
+ mvnCmd.add(MavenCommand.wrapper(runDirPath).toString());
mvnCmd.add("--quiet");
mvnCmd.add("--file");
mvnCmd.add(runDirPath.toRealPath().resolve("pom.xml").toString());
@@ -2272,33 +2224,14 @@ public class Run extends CamelCommand {
writeCamelMainRunLogConfig(logConfig, appName,
loggingOptions.loggingLevel);
// shutdown hook to clean up temp files
- Runtime.getRuntime().addShutdownHook(new Thread(() -> {
- try {
- Process process = processRef.get();
- if (process != null) {
- process.destroy();
- for (int i = 0; i < 30; i++) {
- if (!process.isAlive()) {
- break;
- }
- try {
- Thread.sleep(1000);
- } catch (InterruptedException e) {
- Thread.currentThread().interrupt();
- }
- }
- }
- Files.deleteIfExists(tempPom);
- Files.deleteIfExists(logConfig);
- } catch (Exception e) {
- // ignore
- }
- }));
+ // the process (and those it started) stops with this JVM, then the
files of the run are removed
+ ChildProcess.stopOnExit(processRef::get, () -> {
+ Files.deleteIfExists(tempPom);
+ Files.deleteIfExists(logConfig);
+ });
// find mvnw or fall back to mvn
- String mvnw = FileUtil.isWindows() ? "mvnw.cmd" : "mvnw";
- Path mvnwPath = projectDir.resolve(mvnw);
- String mvnCmd = Files.isExecutable(mvnwPath) ? mvnwPath.toString() :
"mvn";
+ String mvnCmd = MavenCommand.of(projectDir);
List<String> cmd = new ArrayList<>();
cmd.add(mvnCmd);
@@ -2382,32 +2315,13 @@ public class Run extends CamelCommand {
}
// shutdown hook to clean up temp files
- Runtime.getRuntime().addShutdownHook(new Thread(() -> {
- try {
- Process process = processRef.get();
- if (process != null) {
- process.destroy();
- for (int i = 0; i < 30; i++) {
- if (!process.isAlive()) {
- break;
- }
- try {
- Thread.sleep(1000);
- } catch (InterruptedException e) {
- Thread.currentThread().interrupt();
- }
- }
- }
- Files.deleteIfExists(tempPom);
- } catch (Exception e) {
- // ignore
- }
- }));
+ // the process (and those it started) stops with this JVM, then the
files of the run are removed
+ ChildProcess.stopOnExit(processRef::get, () -> {
+ Files.deleteIfExists(tempPom);
+ });
// find mvnw or fall back to mvn
- String mvnw = FileUtil.isWindows() ? "mvnw.cmd" : "mvnw";
- Path mvnwPath = projectDir.resolve(mvnw);
- String mvnCmd = Files.isExecutable(mvnwPath) ? mvnwPath.toString() :
"mvn";
+ String mvnCmd = MavenCommand.of(projectDir);
// log to console and to file in ~/.camel (so camel log and the TUI
can tail the logs)
String appName = resolveExistingProjectAppName(projectDir, model);
@@ -2472,31 +2386,10 @@ public class Run extends CamelCommand {
Path runDirPath = Paths.get(RUN_PLATFORM_DIR,
Long.toString(System.currentTimeMillis()));
if (!this.background) {
// Mark for deletion on exit
- Runtime.getRuntime().addShutdownHook(new Thread(() -> {
- try {
- // We need to wait for the process to exit before doing
any cleanup
- Process process = processRef.get();
- if (process != null) {
- process.destroy();
-
- for (int i = 0; i < 30; i++) {
- if (!process.isAlive()) {
- break;
- }
-
- try {
- Thread.sleep(1000);
- } catch (InterruptedException e) {
- Thread.currentThread().interrupt();
- }
- }
- }
-
- removeDir(runDirPath);
- } catch (Exception e) {
- // Ignore
- }
- }));
+ // the process (and those it started) stops with this JVM, then
the files of the run are removed
+ ChildProcess.stopOnExit(processRef::get, () -> {
+ removeDir(runDirPath);
+ });
}
Files.createDirectories(runDirPath);
@@ -2574,12 +2467,8 @@ public class Run extends CamelCommand {
// run spring-boot via maven
ProcessBuilder pb = new ProcessBuilder();
- String mvnw = "/mvnw";
- if (FileUtil.isWindows()) {
- mvnw = "/mvnw.cmd";
- }
List<String> mvnCmd = new ArrayList<>();
- mvnCmd.add(runDirPath + mvnw);
+ mvnCmd.add(MavenCommand.wrapper(runDirPath).toString());
mvnCmd.add("--quiet");
mvnCmd.add("--file");
mvnCmd.add(runDirPath.toRealPath().resolve("pom.xml").toString());
@@ -2684,35 +2573,16 @@ public class Run extends CamelCommand {
}
// shutdown hook to clean up temp files
- Runtime.getRuntime().addShutdownHook(new Thread(() -> {
- try {
- Process process = processRef.get();
- if (process != null) {
- process.destroy();
- for (int i = 0; i < 30; i++) {
- if (!process.isAlive()) {
- break;
- }
- try {
- Thread.sleep(1000);
- } catch (InterruptedException e) {
- Thread.currentThread().interrupt();
- }
- }
- }
- Files.deleteIfExists(tempPom);
- if (springBootLogback != null) {
- Files.deleteIfExists(springBootLogback);
- }
- } catch (Exception e) {
- // ignore
+ // the process (and those it started) stops with this JVM, then the
files of the run are removed
+ ChildProcess.stopOnExit(processRef::get, () -> {
+ Files.deleteIfExists(tempPom);
+ if (springBootLogback != null) {
+ Files.deleteIfExists(springBootLogback);
}
- }));
+ });
// find mvnw or fall back to mvn
- String mvnw = FileUtil.isWindows() ? "mvnw.cmd" : "mvnw";
- Path mvnwPath = projectDir.resolve(mvnw);
- String mvnCmd = Files.isExecutable(mvnwPath) ? mvnwPath.toString() :
"mvn";
+ String mvnCmd = MavenCommand.of(projectDir);
List<String> cmd = new ArrayList<>();
cmd.add(mvnCmd);
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/ChildProcess.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/ChildProcess.java
new file mode 100644
index 000000000000..d207f12a3bd4
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/ChildProcess.java
@@ -0,0 +1,92 @@
+/*
+ * 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.dsl.jbang.core.common;
+
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Supplier;
+
+/**
+ * A process the CLI starts and stops with itself, such as the Maven of camel
run with a Spring Boot or Quarkus project:
+ * when the CLI exits (such as on Ctrl+C), the process and the processes it
started are stopped, and then the files of
+ * the run are removed.
+ */
+public final class ChildProcess {
+
+ /**
+ * How long a process gets to stop before it is killed, in seconds.
+ */
+ static final int STOP_SECONDS = 30;
+
+ /**
+ * What to remove once the process has stopped.
+ */
+ @FunctionalInterface
+ public interface Cleanup {
+ void run() throws Exception;
+ }
+
+ private ChildProcess() {
+ }
+
+ /**
+ * Stops the process, and the processes it started, when this JVM exits,
and then runs the cleanup.
+ *
+ * @param process the process, or <tt>null</tt> while it has not started
+ * @param cleanup what to remove once the process has stopped
+ */
+ public static void stopOnExit(Supplier<Process> process, Cleanup cleanup) {
+ Runtime.getRuntime().addShutdownHook(new Thread(() -> {
+ Process p = process.get();
+ if (p != null) {
+ stop(p, STOP_SECONDS);
+ }
+ try {
+ cleanup.run();
+ } catch (Exception e) {
+ // best effort, as the JVM exits
+ }
+ }, "CamelChildProcessStop"));
+ }
+
+ /**
+ * Stops the process and the processes it started (such as the app that
Maven runs in a JVM of its own), and kills
+ * those that have not stopped within the seconds.
+ */
+ public static void stop(Process process, int seconds) {
+ List<ProcessHandle> children = process.descendants().toList();
+ process.destroy();
+ children.forEach(ProcessHandle::destroy);
+ long deadline = System.currentTimeMillis() +
TimeUnit.SECONDS.toMillis(seconds);
+ try {
+ if (!process.waitFor(seconds, TimeUnit.SECONDS)) {
+ process.destroyForcibly();
+ }
+ for (ProcessHandle child : children) {
+ long remaining = deadline - System.currentTimeMillis();
+ if (child.isAlive() && remaining > 0) {
+ child.onExit().get(remaining, TimeUnit.MILLISECONDS);
+ }
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ } catch (Exception e) {
+ // still alive: killed below
+ }
+
children.stream().filter(ProcessHandle::isAlive).forEach(ProcessHandle::destroyForcibly);
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/MavenCommand.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/MavenCommand.java
new file mode 100644
index 000000000000..4779a43a95c4
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/MavenCommand.java
@@ -0,0 +1,46 @@
+/*
+ * 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.dsl.jbang.core.common;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import org.apache.camel.util.FileUtil;
+
+/**
+ * The Maven to build or run a project with: its Maven wrapper when it has
one, and otherwise mvn from the path.
+ */
+public final class MavenCommand {
+
+ private MavenCommand() {
+ }
+
+ /**
+ * The Maven wrapper of the directory: mvnw, or mvnw.cmd on Windows (which
may not exist).
+ */
+ public static Path wrapper(Path dir) {
+ return dir.resolve(FileUtil.isWindows() ? "mvnw.cmd" : "mvnw");
+ }
+
+ /**
+ * The Maven wrapper of the directory when it has one that can run, and
otherwise mvn.
+ */
+ public static String of(Path dir) {
+ Path wrapper = wrapper(dir);
+ return Files.isExecutable(wrapper) ? wrapper.toString() : "mvn";
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/PluginHelper.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/PluginHelper.java
index 3081b1689607..6cc8c474c0a1 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/PluginHelper.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/PluginHelper.java
@@ -217,9 +217,10 @@ public final class PluginHelper {
* Gets the active plugins according to the local plugin configuration
file. Performs version check to make sure
* that the current Camel CLI version is able to execute the plugin.
*
- * @param main to exit the CLI process in case of error
- * @param repos custom maven repositories
- * @return map of plugins where key represents the plugin command
and value the plugin instance.
+ * @param main the Camel CLI
+ * @param repos custom maven repositories
+ * @return map of plugins where key represents the plugin
command and value the plugin instance.
+ * @throws CliException (exit code 1) when a plugin is not found, or needs
a newer Camel CLI
*/
public static Map<String, Plugin> getActivePlugins(CamelJBangMain main,
String repos) {
return getActivePlugins(main, repos, null);
@@ -230,10 +231,11 @@ public final class PluginHelper {
* When a target command is specified (and is not "shell"), only the
plugin matching that command is downloaded and
* instantiated — other plugins are skipped entirely, avoiding expensive
Maven resolution.
*
- * @param main to exit the CLI process in case of error
- * @param repos custom maven repositories
- * @param target the target command name to filter by, or null to load
all plugins
- * @return map of plugins where key represents the plugin command
and value the plugin instance.
+ * @param main the Camel CLI
+ * @param repos custom maven repositories
+ * @param target the target command name to filter by, or null to
load all plugins
+ * @return map of plugins where key represents the plugin
command and value the plugin instance.
+ * @throws CliException (exit code 1) when a plugin is not found, or needs
a newer Camel CLI
*/
public static Map<String, Plugin> getActivePlugins(CamelJBangMain main,
String repos, String target) {
Map<String, Plugin> activePlugins = new HashMap<>();
@@ -269,8 +271,7 @@ public final class PluginHelper {
configDirty = true;
}
} else {
- main.getOut().printErr("camel-jbang-plugin-" + command + "
not found. Exit");
- main.quit(CliExitCode.FAILURE);
+ throw CliException.failure("camel-jbang-plugin-" + command
+ " not found");
}
}
if (configDirty) {
@@ -522,9 +523,8 @@ public final class PluginHelper {
}
boolean accept = VersionHelper.isGE(source, first);
if (!accept) {
- main.getOut().printErr("Cannot load plugin camel-jbang-plugin-" +
command + " with version: " + version
- + " because plugin has first version: " +
firstVersion + ". Exit");
- main.quit(CliExitCode.FAILURE);
+ throw CliException.failure("Cannot load plugin
camel-jbang-plugin-" + command + " with version: " + version
+ + " because plugin has first version: "
+ firstVersion);
}
}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/ChildProcessTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/ChildProcessTest.java
new file mode 100644
index 000000000000..19e393f201a5
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/ChildProcessTest.java
@@ -0,0 +1,80 @@
+/*
+ * 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.dsl.jbang.core.common;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.camel.util.StopWatch;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.DisabledOnOs;
+import org.junit.jupiter.api.condition.OS;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.awaitility.Awaitility.await;
+
+/**
+ * A process the CLI started stops with the processes it started (as Maven
starts the app in a JVM of its own), and one
+ * that does not stop in time is killed.
+ */
+@DisabledOnOs(OS.WINDOWS)
+class ChildProcessTest {
+
+ @TempDir
+ Path dir;
+
+ @Test
+ void stopsTheProcessAndTheProcessesItStarted() throws Exception {
+ Process parent = new ProcessBuilder("sh", "-c", "sleep 60 & echo $! >
child.pid; wait")
+ .directory(dir.toFile()).start();
+ Path childPid = dir.resolve("child.pid");
+ await().atMost(10, TimeUnit.SECONDS).until(() ->
Files.exists(childPid) && Files.size(childPid) > 0);
+ long pid = Long.parseLong(Files.readString(childPid).trim());
+ ProcessHandle child = ProcessHandle.of(pid).orElseThrow();
+
+ ChildProcess.stop(parent, 10);
+
+ assertThat(parent.isAlive()).isFalse();
+ await().atMost(10, TimeUnit.SECONDS).until(() -> !child.isAlive());
+ }
+
+ @Test
+ void killsAProcessThatDoesNotStopInTime() throws Exception {
+ // ignores SIGTERM
+ Process process = new ProcessBuilder("sh", "-c", "trap '' TERM; while
true; do sleep 1; done").start();
+ StopWatch watch = new StopWatch();
+
+ ChildProcess.stop(process, 1);
+
+ assertThat(process.waitFor(10, TimeUnit.SECONDS)).isTrue();
+ assertThat(watch.taken()).isLessThan(10000);
+ }
+
+ @Test
+ void theWrapperOfAProjectOrMaven() throws Exception {
+ assertThat(MavenCommand.of(dir)).isEqualTo("mvn");
+
+ Path wrapper = Files.writeString(dir.resolve("mvnw"), "#!/bin/sh\n");
+ assertThat(MavenCommand.wrapper(dir)).isEqualTo(wrapper);
+ // a wrapper that cannot run is not used
+ assertThat(MavenCommand.of(dir)).isEqualTo("mvn");
+ assertThat(wrapper.toFile().setExecutable(true)).isTrue();
+ assertThat(MavenCommand.of(dir)).isEqualTo(wrapper.toString());
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/PluginHelperTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/PluginHelperTest.java
index cff501ef7210..c1fe440e8b81 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/PluginHelperTest.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/PluginHelperTest.java
@@ -192,10 +192,12 @@ public class PluginHelperTest {
writeConfig(buildEntry("fake", camelVersion, jar, staleMtime));
// Stale mtime invalidates the cache. There is no factory-finder entry
and no Maven dependency to
- // download (gav is empty), so the resolver returns empty and quits.
+ // download (gav is empty), so the resolver returns empty and the
plugin is not found.
QuitCapture main = new QuitCapture();
- assertThrows(RuntimeException.class, () ->
PluginHelper.getActivePlugins(main, null, "fake"));
- assertTrue(main.quitCalled, "expected resolver to give up when cache
is invalid and no download path is viable");
+ CliException e = assertThrows(CliException.class, () ->
PluginHelper.getActivePlugins(main, null, "fake"));
+ assertEquals("camel-jbang-plugin-fake not found", e.getMessage(),
+ "expected resolver to give up when cache is invalid and no
download path is viable");
+ assertFalse(main.quitCalled, "the error ends the command, not the
JVM");
}
@Test
@@ -247,11 +249,11 @@ public class PluginHelperTest {
// BEFORE CAMEL-23335: entry has no `resolved` block. resolvePlugin
falls through loadFromCache,
// misses FACTORY_FINDER (no service registered for "fake"), reaches
downloadPlugin with no usable
- // gav, gets nothing back, and quits.
+ // gav, gets nothing back, and the plugin is not found.
writeConfig(buildEntryWithoutResolvedBlock("fake", camelVersion));
QuitCapture before = new QuitCapture();
- assertThrows(RuntimeException.class, () ->
PluginHelper.getActivePlugins(before, null, "fake"));
- assertTrue(before.quitCalled,
+ CliException e = assertThrows(CliException.class, () ->
PluginHelper.getActivePlugins(before, null, "fake"));
+ assertEquals("camel-jbang-plugin-fake not found", e.getMessage(),
"without the resolved-block cache, resolver attempted download
and gave up");
// AFTER CAMEL-23335: entry has a valid `resolved` block.
loadFromCache builds a URLClassLoader
@@ -268,8 +270,7 @@ public class PluginHelperTest {
@Test
void testVersionCheckAcceptsSameSnapshotVersion() {
// CAMEL-24469: a SNAPSHOT build must not be rejected against a plugin
whose firstVersion
- // is the same SNAPSHOT release. Rejection calls quit() which, on the
real main, is System.exit
- // and crashes the surefire fork. This regressed on patch releases
such as 4.22.1-SNAPSHOT.
+ // is the same SNAPSHOT release. This regressed on patch releases such
as 4.22.1-SNAPSHOT.
QuitCapture main = new QuitCapture();
assertThatCode(() -> PluginHelper.versionCheck(main,
"4.22.1-SNAPSHOT", "4.22.1-SNAPSHOT", "fake"))
.doesNotThrowAnyException();
@@ -278,11 +279,13 @@ public class PluginHelperTest {
@Test
void testVersionCheckRejectsOlderVersion() {
- // A plugin requiring a newer firstVersion than the current build must
be rejected (quit).
+ // A plugin requiring a newer firstVersion than the current build must
be rejected: an error of the
+ // command (exit code 1), not System.exit, which ends a JVM that
embeds the CLI
QuitCapture main = new QuitCapture();
- assertThrows(RuntimeException.class,
+ CliException e = assertThrows(CliException.class,
() -> PluginHelper.versionCheck(main, "4.22.1-SNAPSHOT",
"4.23.0", "fake"));
- assertTrue(main.quitCalled, "older build than plugin firstVersion must
be rejected");
+ assertEquals(CliExitCode.FAILURE, e.getExitCode(), "older build than
plugin firstVersion must be rejected");
+ assertFalse(main.quitCalled);
}
private void writeConfig(JsonObject pluginEntry) throws Exception {
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-kubernetes/src/main/java/org/apache/camel/dsl/jbang/core/commands/kubernetes/KubernetesRun.java
b/dsl/camel-jbang/camel-jbang-plugin-kubernetes/src/main/java/org/apache/camel/dsl/jbang/core/commands/kubernetes/KubernetesRun.java
index 4f06110304af..2ef670883a52 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-kubernetes/src/main/java/org/apache/camel/dsl/jbang/core/commands/kubernetes/KubernetesRun.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-kubernetes/src/main/java/org/apache/camel/dsl/jbang/core/commands/kubernetes/KubernetesRun.java
@@ -52,6 +52,7 @@ import
org.apache.camel.dsl.jbang.core.commands.kubernetes.traits.MountTrait;
import org.apache.camel.dsl.jbang.core.commands.kubernetes.traits.TraitHelper;
import org.apache.camel.dsl.jbang.core.commands.kubernetes.traits.model.Traits;
import org.apache.camel.dsl.jbang.core.common.CliException;
+import org.apache.camel.dsl.jbang.core.common.MavenCommand;
import org.apache.camel.dsl.jbang.core.common.Printer;
import org.apache.camel.dsl.jbang.core.common.QuarkusHelper.QuarkusPlatformBom;
import org.apache.camel.dsl.jbang.core.common.RuntimeCompletionCandidates;
@@ -687,14 +688,10 @@ public class KubernetesRun extends KubernetesBaseCommand {
printer().println("Building Camel application ...");
// Run build via Maven
- String mvnw = "/mvnw";
- if (FileUtil.isWindows()) {
- mvnw = "/mvnw.cmd";
- }
ProcessBuilder pb = new ProcessBuilder();
List<String> args = new ArrayList<>();
- args.add(workingDir + mvnw);
+ args.add(MavenCommand.wrapper(Path.of(workingDir)).toString());
if (quiet || !verbose) {
args.add("--quiet");
}
@@ -747,14 +744,10 @@ public class KubernetesRun extends KubernetesBaseCommand {
printer().println("Deploying to %s ...".formatted(clusterType));
// Run build via Maven
- String mvnw = "/mvnw";
- if (FileUtil.isWindows()) {
- mvnw = "/mvnw.cmd";
- }
ProcessBuilder pb = new ProcessBuilder();
List<String> args = new ArrayList<>();
- args.add(workingDir + mvnw);
+ args.add(MavenCommand.wrapper(Path.of(workingDir)).toString());
if (quiet || !verbose) {
args.add("--quiet");
}