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