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 f5015f3b2015 CAMEL-25395: camel-jbang - camel stop and restart wait 
and verify (#27927)
f5015f3b2015 is described below

commit f5015f3b201551ac3287abdccc144fd8258f1775
Author: Claus Ibsen <[email protected]>
AuthorDate: Fri Oct 16 03:21:33 2026 +0200

    CAMEL-25395: camel-jbang - camel stop and restart wait and verify (#27927)
    
    camel stop waits until the integration has stopped, up to --timeout (60s by
    default), and exits 124 with a hint to use --kill when it did not stop in
    time; --no-wait returns at once as before. An integration without a pid file
    is stopped with SIGTERM, which did nothing before. A name or pid that 
matches
    no running integration exits 3.
    
    camel restart stops the integration the same way (terminating it when it
    does not stop within --timeout) and does not start it again while the old
    process is alive (124). It waits until the new integration has started,
    keeps its output in a log file in ~/.camel and prints the path (the log was
    removed on exit); a new integration that stops right away prints the end of
    its log and exits 1, one that is still starting after --timeout exits 124.
    
    Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
---
 .../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc    |  13 ++
 .../modules/ROOT/pages/camel-jbang-managing.adoc   |  16 ++-
 .../pages/jbang-commands/camel-jbang-restart.adoc  |   1 +
 .../pages/jbang-commands/camel-jbang-stop.adoc     |   2 +
 .../META-INF/camel-jbang-commands-metadata.json    |   4 +-
 .../core/commands/process/RestartProcess.java      | 155 ++++++++++++++-------
 .../jbang/core/commands/process/StopProcess.java   |  74 +++++++++-
 .../core/commands/process/RestartProcessTest.java  |  56 +++++++-
 .../core/commands/process/StopProcessTest.java     |  81 ++++++++++-
 9 files changed, 330 insertions(+), 72 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 b4812a11b4e9..cf2ca3006850 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
@@ -363,6 +363,19 @@ The JSON of the listings (`--json`) changes, so that a 
script can use the values
   the state is `status` (it was `state` in `camel get circuit-breaker`). 
`camel get consumer` has `total` instead of
   `poll`. Every row of `camel get processor` has the `name` of its integration.
 
+=== camel-jbang - camel stop and camel restart wait for the integration
+
+* `camel stop` waits until the integration has stopped, up to `--timeout` (60 
seconds by default), and exits with `124`
+  when it has not stopped in time (use `--kill` to stop it at once). Before, 
it asked the integration to stop and exited
+  with `0` at once; `--no-wait` does that. An integration without a pid file 
in `~/.camel` is stopped with `SIGTERM`,
+  which did nothing before.
+* `camel stop` and `camel restart` with a name or pid that matches no running 
integration exit with `3`.
+* `camel restart` waits until the integration has stopped (terminating it when 
it does not stop within `--timeout`), and
+  does not start it again while the old process is alive (exit `124`). It 
waits until the new integration has started,
+  and keeps its output in a log file in `~/.camel`, which it prints (the log 
was removed when the command ended). A new
+  integration that stops right away prints the end of its log and exits with 
`1`; one that is still starting after
+  `--timeout` exits with `124` and is left running.
+
 === 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/docs/user-manual/modules/ROOT/pages/camel-jbang-managing.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-jbang-managing.adoc
index 2f19964de767..4ba5e9bd5f77 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-managing.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-managing.adoc
@@ -27,9 +27,12 @@ Stop by name or PID:
 [source,bash]
 ----
 camel stop dude
-Stopping running Camel integration (pid: 62506)
+Shutting down Camel integration (PID: 62506)
 ----
 
+`camel stop` waits until the integration has stopped, up to `--timeout` (60 
seconds by default). When it has not
+stopped in time, it exits with code `124`, and `--kill` stops it at once. Use 
`--no-wait` to return without waiting.
+
 NOTE: The name is matched as a prefix — `camel stop d` stops all integrations 
starting with _d_.
 To stop exactly one when names overlap (e.g., `dude` and `dude2`), use the PID 
or append `!`: `camel stop dude!`.
 A PID must be a running Camel integration, and a name or PID that matches 
nothing exits with code `3`
@@ -40,15 +43,22 @@ Restart an integration (stop + re-launch):
 [source,bash]
 ----
 camel restart dude
+Stopping Camel integration: dude (PID: 62506)
+Restarted Camel integration: dude (PID: 63010)
+Log: ~/.camel/restart-62506-1234.log
 ----
 
+`camel restart` waits until the integration has stopped, and until the new one 
has started, and keeps the output of
+the new integration in the log file it prints. When the new integration stops 
right away, it prints the end of the log
+and exits with code `1`; when it is still starting after `--timeout`, it exits 
with code `124` and leaves it running.
+
 Stop all integrations (omit the name):
 
 [source,bash]
 ----
 camel stop
-Stopping running Camel integration (pid: 61818)
-Stopping running Camel integration (pid: 62506)
+Shutting down Camel integration (PID: 61818)
+Shutting down Camel integration (PID: 62506)
 ----
 
 === Watch mode
diff --git 
a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-restart.adoc 
b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-restart.adoc
index 3e9f6ff54b43..ac7160674c28 100644
--- 
a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-restart.adoc
+++ 
b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-restart.adoc
@@ -19,6 +19,7 @@ camel restart [options]
 [cols="2,5,1,2",options="header"]
 |===
 | Option | Description | Default | Type
+| `--timeout` | How long to wait for the integration to stop, and again to 
start, in millis or as a duration such as 30s | 60000 | long
 | `-h,--help` | Display the help and sub-commands |  | boolean
 |===
 
diff --git 
a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-stop.adoc 
b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-stop.adoc
index 834a831b3870..652e39ae71be 100644
--- a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-stop.adoc
+++ b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-stop.adoc
@@ -20,6 +20,8 @@ camel stop [options]
 |===
 | Option | Description | Default | Type
 | `--kill` | To force killing the process (SIGKILL) |  | boolean
+| `--no-wait` | Do not wait for the integration to stop |  | boolean
+| `--timeout` | How long to wait for the integration to stop, in millis or as 
a duration such as 30s | 60000 | long
 | `-h,--help` | Display the help and sub-commands |  | boolean
 |===
 
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json
 
b/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json
index 9a8b96f6cab2..f5dc530d4558 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json
@@ -26,13 +26,13 @@
     { "name": "overview", "fullName": "overview", "description": "High-level 
overview of the integrations in a project, optionally explained by AI\/LLM", 
"sourceClass": "org.apache.camel.dsl.jbang.core.commands.Overview", "options": 
[ { "names": "--ai", "description": "Ask an LLM to explain the project (an 
overview, the capabilities, labels and notes of routes that have none) and save 
it in camel-summary.md, marked as AI-assisted", "javaType": "boolean", "type": 
"boolean" }, { "names": " [...]
     { "name": "plugin", "fullName": "plugin", "description": "Manage plugins 
that add sub-commands to this CLI", "sourceClass": 
"org.apache.camel.dsl.jbang.core.commands.plugin.PluginCommand", "options": [ { 
"names": "-h,--help", "description": "Display the help and sub-commands", 
"javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name": "add", 
"fullName": "plugin add", "description": "Add new plugin", "sourceClass": 
"org.apache.camel.dsl.jbang.core.commands.plugin.PluginA [...]
     { "name": "ps", "fullName": "ps", "description": "List running Camel 
integrations", "sourceClass": 
"org.apache.camel.dsl.jbang.core.commands.process.ListProcess", "options": [ { 
"names": "--json", "description": "Output in JSON Format", "javaType": 
"boolean", "type": "boolean" }, { "names": "--pid", "description": "List only 
pid in the output", "javaType": "boolean", "type": "boolean" }, { "names": 
"--remote", "description": "Break down counters into remote\/total pairs", 
"javaType": [...]
-    { "name": "restart", "fullName": "restart", "description": "Restarts 
running Camel integrations (stop + re-launch)", "sourceClass": 
"org.apache.camel.dsl.jbang.core.commands.process.RestartProcess", "options": [ 
{ "names": "-h,--help", "description": "Display the help and sub-commands", 
"javaType": "boolean", "type": "boolean" } ] },
+    { "name": "restart", "fullName": "restart", "description": "Restarts 
running Camel integrations (stop + re-launch)", "sourceClass": 
"org.apache.camel.dsl.jbang.core.commands.process.RestartProcess", "options": [ 
{ "names": "--timeout", "description": "How long to wait for the integration to 
stop, and again to start, in millis or as a duration such as 30s", 
"defaultValue": "60000", "javaType": "long", "type": "integer" }, { "names": 
"-h,--help", "description": "Display the help and su [...]
     { "name": "run", "fullName": "run", "description": "Run as local Camel 
integration", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.Run", 
"options": [ { "names": "--background", "description": "Run in the background", 
"defaultValue": "false", "javaType": "boolean", "type": "boolean" }, { "names": 
"--background-wait", "description": "To wait for run in background to startup 
successfully, before returning", "defaultValue": "true", "javaType": "boolean", 
"type": "boolean" }, { [...]
     { "name": "sbom", "fullName": "sbom", "description": "Generate a CycloneDX 
or SPDX SBOM for a specific project", "sourceClass": 
"org.apache.camel.dsl.jbang.core.commands.SBOMGenerator", "options": [ { 
"names": "--build-property", "description": "Maven build properties, ex. 
--build-property=prop1=foo", "javaType": "java.util.List", "type": "array" }, { 
"names": "--camel-spring-boot-version", "description": "Camel version to use 
with Spring Boot", "javaType": "java.lang.String", "type" [...]
     { "name": "script", "fullName": "script", "description": "Run Camel 
integration as shell script for terminal scripting", "sourceClass": 
"org.apache.camel.dsl.jbang.core.commands.Script", "options": [ { "names": 
"--logging", "description": "Can be used to turn on logging (logs to file in 
<user home>\/.camel directory)", "defaultValue": "false", "javaType": 
"boolean", "type": "boolean" }, { "names": "--logging-level", "description": 
"Logging level (ERROR, WARN, INFO, DEBUG, TRACE)", "d [...]
     { "name": "semantic", "fullName": "semantic", "description": "List 
semantic definitions and expert contracts", "sourceClass": 
"org.apache.camel.dsl.jbang.core.commands.action.CamelSemantic", "options": [ { 
"names": "--expert", "description": "Show the operations and parameter contract 
of this expert", "javaType": "java.lang.String", "type": "string" }, { "names": 
"--json", "description": "Output a single JSON document", "javaType": 
"boolean", "type": "boolean" }, { "names": "--timeou [...]
     { "name": "shell", "fullName": "shell", "description": "Interactive Camel 
CLI shell.", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.Shell", 
"options": [ { "names": "-h,--help", "description": "Display the help and 
sub-commands", "javaType": "boolean", "type": "boolean" } ] },
-    { "name": "stop", "fullName": "stop", "description": "Shuts down running 
Camel integrations", "sourceClass": 
"org.apache.camel.dsl.jbang.core.commands.process.StopProcess", "options": [ { 
"names": "--kill", "description": "To force killing the process (SIGKILL)", 
"javaType": "boolean", "type": "boolean" }, { "names": "-h,--help", 
"description": "Display the help and sub-commands", "javaType": "boolean", 
"type": "boolean" } ] },
+    { "name": "stop", "fullName": "stop", "description": "Shuts down running 
Camel integrations", "sourceClass": 
"org.apache.camel.dsl.jbang.core.commands.process.StopProcess", "options": [ { 
"names": "--kill", "description": "To force killing the process (SIGKILL)", 
"javaType": "boolean", "type": "boolean" }, { "names": "--no-wait", 
"description": "Do not wait for the integration to stop", "javaType": 
"boolean", "type": "boolean" }, { "names": "--timeout", "description": "How 
long to wa [...]
     { "name": "top", "fullName": "top", "description": "Top status of Camel 
integrations", "sourceClass": 
"org.apache.camel.dsl.jbang.core.commands.process.CamelTop", "options": [ { 
"names": "--watch", "description": "Execute periodically and showing output 
fullscreen", "javaType": "boolean", "type": "boolean" }, { "names": 
"-h,--help", "description": "Display the help and sub-commands", "javaType": 
"boolean", "type": "boolean" } ], "subcommands": [ { "name": "context", 
"fullName": "top  [...]
     { "name": "trace", "fullName": "trace", "description": "Tail message 
traces from running Camel integrations", "sourceClass": 
"org.apache.camel.dsl.jbang.core.commands.action.CamelTraceAction", "options": 
[ { "names": "--action", "description": "Action to start, stop, clear, list 
status, or dump traces", "defaultValue": "status", "javaType": 
"java.lang.String", "type": "string" }, { "names": "--ago", "description": "Use 
ago instead of yyyy-MM-dd HH:mm:ss in timestamp.", "javaType": "b [...]
     { "name": "transform", "fullName": "transform", "description": "Transform 
message or Camel routes", "sourceClass": 
"org.apache.camel.dsl.jbang.core.commands.TransformCommand", "options": [ { 
"names": "-h,--help", "description": "Display the help and sub-commands", 
"javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name": 
"dataweave", "fullName": "transform dataweave", "description": "Convert 
DataWeave scripts to DataSonnet format", "sourceClass": "org.apache.camel.dsl. 
[...]
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/process/RestartProcess.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/process/RestartProcess.java
index 7ae534377ef9..7c078e419ea7 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/process/RestartProcess.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/process/RestartProcess.java
@@ -17,18 +17,22 @@
 package org.apache.camel.dsl.jbang.core.commands.process;
 
 import java.io.File;
+import java.io.IOException;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
 import java.util.Optional;
-import java.util.concurrent.CompletableFuture;
-import java.util.concurrent.TimeUnit;
 
 import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain;
-import org.apache.camel.dsl.jbang.core.common.CliExitCode;
-import org.apache.camel.dsl.jbang.core.common.ProcessHelper;
+import org.apache.camel.dsl.jbang.core.common.CliException;
+import org.apache.camel.dsl.jbang.core.common.CommandLineHelper;
+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.ProcessFinder.CamelProcess;
+import org.apache.camel.dsl.jbang.core.common.TimeoutConverter;
+import org.apache.camel.util.TimeUtils;
 import org.apache.camel.util.json.JsonObject;
 import picocli.CommandLine;
 import picocli.CommandLine.Command;
@@ -42,38 +46,30 @@ import picocli.CommandLine.Command;
                  "  camel restart 12345" })
 public class RestartProcess extends ProcessBaseCommand {
 
+    /**
+     * The phase of a Camel that has started (see the status file).
+     */
+    private static final int STARTED = 5;
+
     @CommandLine.Parameters(description = "Name or pid of running Camel 
integration", arity = "1")
     String name;
 
+    @CommandLine.Option(names = { "--timeout" }, defaultValue = "60000", 
converter = TimeoutConverter.class,
+                        description = "How long to wait for the integration to 
stop, and again to start, in millis or as a"
+                                      + " duration such as 30s")
+    long timeout = 60000;
+
     public RestartProcess(CamelJBangMain main) {
         super(main);
     }
 
     @Override
     public Integer doCall() throws Exception {
-        List<Long> pids = findPids(name);
-        if (pids.isEmpty()) {
-            printer().printErr("No matching Camel integration found: " + name);
-            return CliExitCode.NOT_FOUND;
-        }
-        if (pids.size() > 1) {
-            printer().printErr("Multiple integrations match '" + name + "'. 
Please be more specific.");
-            return CliExitCode.NOT_FOUND;
-        }
-
-        long pid = pids.get(0);
-        ProcessHandle ph = ProcessHandle.of(pid).orElse(null);
-        if (ph == null) {
-            printer().printErr("Process not found: " + pid);
-            return CliExitCode.NOT_FOUND;
-        }
-
-        // resolve name for display
-        String displayName = name;
-        JsonObject root = loadStatus(pid);
-        if (root != null) {
-            displayName = ProcessHelper.extractName(root, ph);
-        }
+        CamelProcess process = ProcessFinder.requireOne(name);
+        long pid = process.pid();
+        ProcessHandle ph = ProcessHandle.of(pid)
+                .orElseThrow(() -> CliException.notFound("No running Camel 
integration matches: " + name));
+        String displayName = process.name();
 
         // capture command line before stopping
         Optional<String> cmdOpt = ph.info().command();
@@ -82,11 +78,9 @@ public class RestartProcess extends ProcessBaseCommand {
 
         // get working directory from status file
         String directory = null;
-        if (root != null) {
-            JsonObject runtime = (JsonObject) root.get("runtime");
-            if (runtime != null) {
-                directory = runtime.getString("directory");
-            }
+        JsonObject runtime = (JsonObject) process.status().get("runtime");
+        if (runtime != null) {
+            directory = runtime.getString("directory");
         }
 
         // build the command to re-launch
@@ -99,40 +93,93 @@ public class RestartProcess extends ProcessBaseCommand {
         }
 
         if (cmd.isEmpty()) {
-            printer().println("Cannot restart: command line not available for 
PID " + pid);
-            return 1;
+            throw CliException.failure("Cannot restart: command line not 
available for PID " + pid);
         }
 
-        // stop the process gracefully
-        printer().println("Stopping Camel integration: " + displayName + " 
(PID: " + pid + ")");
-        ph.destroy();
-
-        // wait for termination
-        CompletableFuture<ProcessHandle> exitFuture = 
ph.onExit().toCompletableFuture();
-        try {
-            exitFuture.get(10, TimeUnit.SECONDS);
-        } catch (Exception e) {
-            printer().println("Graceful shutdown timed out, force killing...");
-            ph.destroyForcibly();
-            Thread.sleep(500);
-        }
+        stop(ph, displayName);
 
-        // re-launch
+        // re-launch, with its output in a log file that stays (the path is 
printed)
         ProcessBuilder pb = new ProcessBuilder(cmd);
         if (directory != null) {
             pb.directory(new File(directory));
         }
         pb.redirectErrorStream(true);
-        Path outputFile = Files.createTempFile("camel-restart-", ".log");
-        outputFile.toFile().deleteOnExit();
-        pb.redirectOutput(outputFile.toFile());
-        Process process = pb.start();
-
-        printer().println("Restarted Camel integration: " + displayName + " 
(PID: " + process.pid() + ")");
+        Path log = Files.createTempFile(CommandLineHelper.getCamelDir(), 
"restart-" + pid + "-", ".log");
+        pb.redirectOutput(log.toFile());
+        Process started = pb.start();
 
+        awaitStarted(started, displayName, log);
+        printer().println("Restarted Camel integration: " + displayName + " 
(PID: " + started.pid() + ")");
+        printer().println("Log: " + log);
         return 0;
     }
 
+    /**
+     * Stops the integration like camel stop does, and terminates it when it 
does not stop in time: the old one must be
+     * gone before the new one starts.
+     */
+    private void stop(ProcessHandle ph, String displayName) {
+        long pid = ph.pid();
+        printer().println("Stopping Camel integration: " + displayName + " 
(PID: " + pid + ")");
+        Path pidFile = 
CommandLineHelper.getCamelDir().resolve(Long.toString(pid));
+        if (Files.exists(pidFile)) {
+            PathUtils.deleteFile(pidFile);
+        } else {
+            ph.destroy();
+        }
+        if (StopProcess.awaitExit(pid, System.currentTimeMillis() + timeout)) {
+            return;
+        }
+        printer().printWarn("Camel integration (PID: " + pid + ") did not stop 
within "
+                            + TimeUtils.printDuration(timeout) + "; 
terminating it");
+        ph.destroy();
+        if (StopProcess.awaitExit(pid, System.currentTimeMillis() + 10000)) {
+            return;
+        }
+        ph.destroyForcibly();
+        if (!StopProcess.awaitExit(pid, System.currentTimeMillis() + 5000)) {
+            throw CliException.timeout("Camel integration (PID: " + pid + ") 
did not stop, so it is not restarted");
+        }
+    }
+
+    /**
+     * Waits until the new integration has started.
+     *
+     * @throws CliException with exit code 1 when it stopped right away, or 
124 when it did not start in time (it is
+     *                      left running)
+     */
+    private void awaitStarted(Process started, String displayName, Path log) 
throws Exception {
+        long deadline = System.currentTimeMillis() + timeout;
+        while (System.currentTimeMillis() < deadline) {
+            if (!started.isAlive()) {
+                printer().printDiagnostic(tail(log, 20));
+                throw CliException.failure("Camel integration " + displayName 
+ " stopped right after the restart (exit code "
+                                           + started.exitValue() + "); the log 
is in " + log);
+            }
+            JsonObject status = loadStatus(started.pid());
+            if (status != null && status.get("context") instanceof JsonObject 
context
+                    && context.getIntegerOrDefault("phase", 0) == STARTED) {
+                return;
+            }
+            Thread.sleep(200);
+        }
+        throw CliException.timeout("Camel integration " + displayName + " 
(PID: " + started.pid() + ") did not start within "
+                                   + TimeUtils.printDuration(timeout) + "; it 
is still starting, the log is in "
+                                   + log);
+    }
+
+    /**
+     * The last lines of the log.
+     */
+    static String tail(Path log, int lines) {
+        try {
+            List<String> all = Files.readAllLines(log);
+            return String.join(System.lineSeparator(), all.subList(Math.max(0, 
all.size() - lines), all.size()));
+        } catch (IOException e) {
+            return "";
+        }
+    }
+
     static List<String> parseCommandLine(String commandLine) {
         List<String> tokens = new ArrayList<>();
         StringBuilder current = new StringBuilder();
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/process/StopProcess.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/process/StopProcess.java
index 78721470eb19..a9955a14ce95 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/process/StopProcess.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/process/StopProcess.java
@@ -18,11 +18,20 @@ package org.apache.camel.dsl.jbang.core.commands.process;
 
 import java.nio.file.Files;
 import java.nio.file.Path;
+import java.util.ArrayList;
 import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.stream.Collectors;
 
 import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain;
+import org.apache.camel.dsl.jbang.core.common.CliException;
 import org.apache.camel.dsl.jbang.core.common.CommandLineHelper;
 import org.apache.camel.dsl.jbang.core.common.PathUtils;
+import org.apache.camel.dsl.jbang.core.common.TimeoutConverter;
+import org.apache.camel.util.TimeUtils;
 import picocli.CommandLine;
 import picocli.CommandLine.Command;
 
@@ -32,6 +41,7 @@ import picocli.CommandLine.Command;
                  "  camel stop hello",
                  "  camel stop *",
                  "  camel stop 12345",
+                 "  camel stop hello --timeout=2m",
                  "  camel stop hello --kill" })
 public class StopProcess extends ProcessBaseCommand {
 
@@ -42,33 +52,87 @@ public class StopProcess extends ProcessBaseCommand {
                         description = "To force killing the process (SIGKILL)")
     boolean kill;
 
+    @CommandLine.Option(names = { "--timeout" }, defaultValue = "60000", 
converter = TimeoutConverter.class,
+                        description = "How long to wait for the integration to 
stop, in millis or as a duration such as 30s")
+    long timeout = 60000;
+
+    @CommandLine.Option(names = { "--no-wait" },
+                        description = "Do not wait for the integration to 
stop")
+    boolean noWait;
+
     public StopProcess(CamelJBangMain main) {
         super(main);
     }
 
     @Override
     public Integer doCall() throws Exception {
+        List<Long> pids = findIntegrationPids(name);
 
-        List<Long> pids = findPids(name);
-
-        // stop by deleting the pid file
         for (Long pid : pids) {
             Path pidFile = 
CommandLineHelper.getCamelDir().resolve(Long.toString(pid));
             if (Files.exists(pidFile)) {
+                // the integration stops when its pid file is gone
                 printer().println("Shutting down Camel integration (PID: " + 
pid + ")");
                 PathUtils.deleteFile(pidFile);
+            } else if (!kill) {
+                printer().println("Shutting down Camel integration (PID: " + 
pid + ") with SIGTERM");
+                ProcessHandle.of(pid).ifPresent(ProcessHandle::destroy);
             }
         }
-        for (Long pid : pids) {
-            if (kill) {
+        if (kill) {
+            for (Long pid : pids) {
                 ProcessHandle.of(pid).ifPresent(ph -> {
                     printer().println("Killing Camel integration (PID: " + pid 
+ ")");
                     ph.destroyForcibly();
                 });
             }
         }
+        if (noWait) {
+            return 0;
+        }
 
+        long deadline = System.currentTimeMillis() + timeout;
+        List<Long> running = new ArrayList<>();
+        for (Long pid : pids) {
+            if (!awaitExit(pid, deadline)) {
+                running.add(pid);
+            }
+        }
+        if (!running.isEmpty()) {
+            throw CliException.timeout("Camel integration (PID: "
+                                       + 
running.stream().map(String::valueOf).collect(Collectors.joining(", "))
+                                       + ") did not stop within " + 
TimeUtils.printDuration(timeout)
+                                       + "; use --kill");
+        }
         return 0;
     }
 
+    /**
+     * Waits until the process has exited.
+     *
+     * @param  pid      the process
+     * @param  deadline until when to wait, in epoch millis
+     * @return          whether the process is gone
+     */
+    static boolean awaitExit(long pid, long deadline) {
+        Optional<ProcessHandle> ph = ProcessHandle.of(pid);
+        if (ph.isEmpty()) {
+            return true;
+        }
+        long remaining = deadline - System.currentTimeMillis();
+        try {
+            if (remaining > 0) {
+                ph.get().onExit().get(remaining, TimeUnit.MILLISECONDS);
+            }
+            return !ph.get().isAlive();
+        } catch (TimeoutException e) {
+            return false;
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            return false;
+        } catch (ExecutionException e) {
+            return !ph.get().isAlive();
+        }
+    }
+
 }
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/process/RestartProcessTest.java
 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/process/RestartProcessTest.java
index bbb600290cf4..f9b42a7fddae 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/process/RestartProcessTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/process/RestartProcessTest.java
@@ -16,17 +16,27 @@
  */
 package org.apache.camel.dsl.jbang.core.commands.process;
 
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Optional;
 import java.util.stream.Stream;
 
 import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain;
+import org.apache.camel.dsl.jbang.core.common.CliException;
+import org.apache.camel.dsl.jbang.core.common.CliExitCode;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.api.io.TempDir;
 import org.mockito.MockedStatic;
 import org.mockito.junit.jupiter.MockitoExtension;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
 
 // TODO: add tests for the successful restart path once 
ProcessBuilder/Runtime.exec can be mocked
 //       or an integration-test harness is available. The current tests only 
cover early-exit guards.
@@ -43,10 +53,10 @@ class RestartProcessTest extends ProcessCommandTestSupport {
             mocked.when(ProcessHandle::current).thenReturn(current);
             mocked.when(ProcessHandle::allProcesses).thenAnswer(inv -> 
Stream.empty());
 
-            int exit = command.doCall();
+            CliException e = assertThrows(CliException.class, command::doCall);
 
-            assertEquals(3, exit, "Should return not found when no process 
matches");
-            assertTrue(printer.getStderr().contains("No matching"), "Should 
print no-match message on stderr");
+            assertEquals(CliExitCode.NOT_FOUND, e.getExitCode(), "Should 
return not found when no process matches");
+            assertEquals("No running Camel integration matches: 
nonExistentApp", e.getMessage());
         }
     }
 
@@ -66,10 +76,44 @@ class RestartProcessTest extends ProcessCommandTestSupport {
             mocked.when(ProcessHandle::current).thenReturn(current);
             mocked.when(ProcessHandle::allProcesses).thenAnswer(inv -> 
Stream.of(ph1, ph2));
 
-            int exit = command.doCall();
+            CliException e = assertThrows(CliException.class, command::doCall);
 
-            assertEquals(3, exit, "Should return not found when multiple 
processes match");
-            assertTrue(printer.getStderr().contains("Multiple"), "Should print 
ambiguity message on stderr");
+            assertEquals(CliExitCode.NOT_FOUND, e.getExitCode(), "Should 
return not found when multiple processes match");
+            assertTrue(e.getMessage().contains("matches 2 running Camel 
integrations"), e.getMessage());
         }
     }
+
+    @Test
+    void testCommandLineNotAvailableIsAFailure() throws Exception {
+        writeStatusFile(TEST_PID, buildContextStatus("myApp", 5));
+
+        RestartProcess command = new RestartProcess(new 
CamelJBangMain().withPrinter(printer));
+        command.name = "myApp";
+
+        try (MockedStatic<ProcessHandle> mocked = 
mockStatic(ProcessHandle.class)) {
+            ProcessHandle ph = mockProcessHandle(TEST_PID);
+            ProcessHandle current = mockCurrentHandle();
+            mocked.when(ProcessHandle::current).thenReturn(current);
+            mocked.when(ProcessHandle::allProcesses).thenAnswer(inv -> 
Stream.of(ph));
+            mocked.when(() -> 
ProcessHandle.of(TEST_PID)).thenReturn(Optional.of(ph));
+            when(ph.info().command()).thenReturn(Optional.empty());
+            when(ph.info().arguments()).thenReturn(Optional.empty());
+
+            CliException e = assertThrows(CliException.class, command::doCall);
+
+            assertEquals(CliExitCode.FAILURE, e.getExitCode());
+            assertEquals("Cannot restart: command line not available for PID " 
+ TEST_PID, e.getMessage());
+            // the integration is not stopped
+            verify(ph, never()).destroy();
+        }
+    }
+
+    @Test
+    void testTailOfTheLog(@TempDir Path dir) throws Exception {
+        Path log = dir.resolve("restart.log");
+        Files.writeString(log, "one\ntwo\nthree\n");
+
+        assertEquals("two" + System.lineSeparator() + "three", 
RestartProcess.tail(log, 2));
+        assertEquals("", RestartProcess.tail(dir.resolve("missing.log"), 2));
+    }
 }
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/process/StopProcessTest.java
 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/process/StopProcessTest.java
index db63aecea085..10912084636a 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/process/StopProcessTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/process/StopProcessTest.java
@@ -19,9 +19,12 @@ package org.apache.camel.dsl.jbang.core.commands.process;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
 import java.util.stream.Stream;
 
 import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain;
+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.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
@@ -30,9 +33,11 @@ import org.mockito.junit.jupiter.MockitoExtension;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.Mockito.mockStatic;
 import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
 
 @ExtendWith(MockitoExtension.class)
 class StopProcessTest extends ProcessCommandTestSupport {
@@ -74,6 +79,7 @@ class StopProcessTest extends ProcessCommandTestSupport {
             mocked.when(ProcessHandle::current).thenReturn(current);
             mocked.when(ProcessHandle::allProcesses).thenAnswer(inv -> 
Stream.of(ph));
             mocked.when(() -> 
ProcessHandle.of(TEST_PID)).thenReturn(Optional.of(ph));
+            
when(ph.onExit()).thenReturn(CompletableFuture.completedFuture(ph));
 
             int exit = command.doCall();
 
@@ -83,6 +89,76 @@ class StopProcessTest extends ProcessCommandTestSupport {
         }
     }
 
+    @Test
+    void testStopWaitsUntilTheProcessHasExited() throws Exception {
+        writeStatusFile(TEST_PID, buildContextStatus("myApp", 5));
+        Path pidFile = 
CommandLineHelper.getCamelDir().resolve(Long.toString(TEST_PID));
+        Files.writeString(pidFile, "");
+
+        StopProcess command = new StopProcess(new 
CamelJBangMain().withPrinter(printer));
+        command.timeout = 300;
+
+        try (MockedStatic<ProcessHandle> mocked = 
mockStatic(ProcessHandle.class)) {
+            ProcessHandle ph = mockProcessHandle(TEST_PID);
+            ProcessHandle current = mockCurrentHandle();
+            mocked.when(ProcessHandle::current).thenReturn(current);
+            mocked.when(ProcessHandle::allProcesses).thenAnswer(inv -> 
Stream.of(ph));
+            mocked.when(() -> 
ProcessHandle.of(TEST_PID)).thenReturn(Optional.of(ph));
+            // the integration does not stop
+            when(ph.onExit()).thenReturn(new CompletableFuture<>());
+
+            CliException e = assertThrows(CliException.class, command::doCall);
+
+            assertEquals(CliExitCode.TIMEOUT, e.getExitCode());
+            assertTrue(e.getMessage().contains("did not stop within") && 
e.getMessage().endsWith("use --kill"),
+                    e.getMessage());
+        }
+    }
+
+    @Test
+    void testStopWithoutWaiting() throws Exception {
+        writeStatusFile(TEST_PID, buildContextStatus("myApp", 5));
+        Path pidFile = 
CommandLineHelper.getCamelDir().resolve(Long.toString(TEST_PID));
+        Files.writeString(pidFile, "");
+
+        StopProcess command = new StopProcess(new 
CamelJBangMain().withPrinter(printer));
+        command.noWait = true;
+
+        try (MockedStatic<ProcessHandle> mocked = 
mockStatic(ProcessHandle.class)) {
+            ProcessHandle ph = mockProcessHandle(TEST_PID);
+            ProcessHandle current = mockCurrentHandle();
+            mocked.when(ProcessHandle::current).thenReturn(current);
+            mocked.when(ProcessHandle::allProcesses).thenAnswer(inv -> 
Stream.of(ph));
+
+            int exit = command.doCall();
+
+            assertEquals(0, exit);
+            assertFalse(Files.exists(pidFile), "Pid file should be deleted to 
signal graceful stop");
+        }
+    }
+
+    @Test
+    void testStopWithoutPidFileSendsSigterm() throws Exception {
+        writeStatusFile(TEST_PID, buildContextStatus("myApp", 5));
+
+        StopProcess command = new StopProcess(new 
CamelJBangMain().withPrinter(printer));
+
+        try (MockedStatic<ProcessHandle> mocked = 
mockStatic(ProcessHandle.class)) {
+            ProcessHandle ph = mockProcessHandle(TEST_PID);
+            ProcessHandle current = mockCurrentHandle();
+            mocked.when(ProcessHandle::current).thenReturn(current);
+            mocked.when(ProcessHandle::allProcesses).thenAnswer(inv -> 
Stream.of(ph));
+            mocked.when(() -> 
ProcessHandle.of(TEST_PID)).thenReturn(Optional.of(ph));
+            
when(ph.onExit()).thenReturn(CompletableFuture.completedFuture(ph));
+
+            int exit = command.doCall();
+
+            assertEquals(0, exit);
+            verify(ph).destroy();
+            assertTrue(printer.getOutput().contains("with SIGTERM"), 
printer.getOutput());
+        }
+    }
+
     @Test
     void testStopWithNoMatchingProcessDoesNothing() throws Exception {
         StopProcess command = new StopProcess(new 
CamelJBangMain().withPrinter(printer));
@@ -93,9 +169,10 @@ class StopProcessTest extends ProcessCommandTestSupport {
             mocked.when(ProcessHandle::current).thenReturn(current);
             mocked.when(ProcessHandle::allProcesses).thenAnswer(inv -> 
Stream.empty());
 
-            int exit = command.doCall();
+            CliException e = assertThrows(CliException.class, command::doCall);
 
-            assertEquals(0, exit);
+            assertEquals(CliExitCode.NOT_FOUND, e.getExitCode());
+            assertEquals("No running Camel integration matches: 
nonExistentApp", e.getMessage());
             assertEquals("", printer.getOutput().trim(), "No output expected 
when no processes match");
         }
     }

Reply via email to