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

Reply via email to