This is an automated email from the ASF dual-hosted git repository.
Croway 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 750325d0e737 CAMEL-26397: camel-jbang - AI tools: read the whole
command line of an integration on Linux (#28682)
750325d0e737 is described below
commit 750325d0e73761f4649df828fca7a65f855902b1
Author: Federico Mariani <[email protected]>
AuthorDate: Thu Oct 29 13:45:21 2026 +0100
CAMEL-26397: camel-jbang - AI tools: read the whole command line of an
integration on Linux (#28682)
On Linux the JDK reads one page (4 KB) of /proc/<pid>/cmdline: for a longer
command line ProcessHandle.Info has no arguments, and its command line is
cut
at 4096 characters. camel run started through jbang always has a longer one,
as its classpath alone is about 15 KB. So camel_control restart always
failed
("its command line is not available"), camel_write_file and camel_edit_file
did
not find the integration that runs the directory in dev mode (no reload
result), camel_run of that directory gave its generic message, and camel
restart parsed the cut command line, relaunching a broken classpath.
ProcessHelper.commandLine reads /proc/<pid>/cmdline when the JDK knows a
process but not all of its arguments. ProcessHelper.commandLineText is the
drop-in for Info.commandLine() that does the same, so the name that camel ps
shows and the JBang platform of camel get and camel top are found on Linux
as
on macOS. IntegrationLauncher also keeps the command that each process it
started was started with, until the process ends, and reads it first: a
restart works where the OS gives no arguments at all (the JDK gives none on
Windows), and a second restart works too. camel restart parses
Info.commandLine() only when it was not cut.
---
.../core/commands/ai/IntegrationLauncher.java | 58 +++++--
.../core/commands/process/CamelContextStatus.java | 2 +-
.../core/commands/process/CamelContextTop.java | 2 +-
.../core/commands/process/RestartProcess.java | 23 +--
.../camel/dsl/jbang/core/common/ProcessHelper.java | 136 ++++++++++++++-
.../ai/IntegrationLauncherCommandLineTest.java | 190 +++++++++++++++++++++
.../core/commands/process/RestartProcessTest.java | 78 ++++++++-
.../dsl/jbang/core/common/ProcessHelperTest.java | 161 +++++++++++++++++
8 files changed, 615 insertions(+), 35 deletions(-)
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/IntegrationLauncher.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/IntegrationLauncher.java
index ed6c16f4549e..a7e7b99bc8f9 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/IntegrationLauncher.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/IntegrationLauncher.java
@@ -21,8 +21,6 @@ import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -34,6 +32,7 @@ import java.util.regex.Pattern;
import org.apache.camel.dsl.jbang.core.common.BackgroundLog;
import org.apache.camel.dsl.jbang.core.common.LauncherHelper;
+import org.apache.camel.dsl.jbang.core.common.ProcessHelper;
import org.apache.camel.dsl.jbang.core.common.RuntimeHelper;
import org.apache.camel.util.json.JsonArray;
import org.apache.camel.util.json.JsonObject;
@@ -77,6 +76,13 @@ public final class IntegrationLauncher {
*/
private static final Map<Path, ProcessHandle> LAUNCHED = new
ConcurrentHashMap<>();
+ /**
+ * CAMEL-26397: the command that each process this JVM started (camel_run,
camel_control restart) was started with,
+ * by its pid, until the process ends. A restart starts it again with that
command: the OS does not always give the
+ * arguments of a process back (the JDK gives none on Windows).
+ */
+ private static final Map<Long, List<String>> COMMANDS = new
ConcurrentHashMap<>();
+
/**
* CAMEL-25561: the name of an integration whose restart failed, by its
directory. The failed restart leaves it
* stopped, and camel_run without a name would start it again under the
default name (from the first file), which
@@ -146,6 +152,7 @@ public final class IntegrationLauncher {
result.put("error", "Cannot start camel run: " +
e.getMessage());
return result;
}
+ keepCommand(process, cmd);
if (sourceDir) {
LAUNCHED.put(key(directory), process.toHandle());
}
@@ -439,7 +446,7 @@ public final class IntegrationLauncher {
/** What the agent can do with the integration that runs from the
directory, by how it was started. */
private static String runningMessage(String name, long pid) {
String prefix = name + " (pid " + pid + ") already runs from this
directory; a second copy was not started.";
- List<String> args = ProcessHandle.of(pid).flatMap(ph ->
ph.info().arguments()).map(Arrays::asList).orElse(null);
+ List<String> args = arguments(pid);
if (args == null) {
return prefix + " camel_control stop stops it, then camel_run
starts the directory again.";
}
@@ -464,8 +471,7 @@ public final class IntegrationLauncher {
* cannot be read, as before.
*/
static boolean watchesFile(long pid, String file) {
- List<String> args = ProcessHandle.of(pid).flatMap(ph ->
ph.info().arguments()).map(Arrays::asList).orElse(null);
- return watchesFile(args, file);
+ return watchesFile(arguments(pid), file);
}
static boolean watchesFile(List<String> args, String file) {
@@ -488,11 +494,36 @@ public final class IntegrationLauncher {
if (running == null) {
return null;
}
- List<String> args = ProcessHandle.of(running.pid()).flatMap(ph ->
ph.info().arguments()).map(Arrays::asList)
- .orElse(null);
+ List<String> args = arguments(running.pid());
return args != null && args.contains("--dev") ? running : null;
}
+ /**
+ * The command line of a process: the command this JVM started it with,
else the one the OS has, all of it (see
+ * {@link ProcessHelper#commandLine(ProcessHandle)}); null when neither is
known.
+ */
+ private static List<String> commandLine(long pid) {
+ List<String> started = COMMANDS.get(pid);
+ if (started != null) {
+ return started;
+ }
+ return
ProcessHandle.of(pid).map(ProcessHelper::commandLine).orElse(null);
+ }
+
+ /** The arguments of a process: its command line without the command, or
null when it is not known. */
+ private static List<String> arguments(long pid) {
+ List<String> cmd = commandLine(pid);
+ return cmd != null && !cmd.isEmpty() ? cmd.subList(1, cmd.size()) :
null;
+ }
+
+ /** Keeps the command a process was started with, until it ends. */
+ private static void keepCommand(Process process, List<String> cmd) {
+ long pid = process.pid();
+ List<String> started = List.copyOf(cmd);
+ COMMANDS.put(pid, started);
+ process.onExit().thenRun(() -> COMMANDS.remove(pid, started));
+ }
+
/** The integration that runs with this directory as its working
directory, or null. */
private static RuntimeHelper.ProcessInfo runningFrom(Path directory) {
Path wanted = directory.toAbsolutePath().normalize();
@@ -627,13 +658,10 @@ public final class IntegrationLauncher {
throw new ToolExecutionException("No process with pid " + pid);
}
ProcessHandle ph = handle.get();
- ProcessHandle.Info info = ph.info();
- List<String> cmd = new ArrayList<>();
- if (info.command().isPresent() && info.arguments().isPresent()) {
- cmd.add(info.command().get());
- Collections.addAll(cmd, info.arguments().get());
- }
- if (cmd.isEmpty()) {
+ // the command it was started with when this JVM started it, else its
command line, also one that the JDK cuts
+ // short on Linux (CAMEL-26397)
+ List<String> cmd = commandLine(pid);
+ if (cmd == null || cmd.isEmpty()) {
throw new ToolExecutionException(
"Cannot restart pid " + pid + ": its command line is not
available; stop it and run it again");
}
@@ -662,6 +690,8 @@ public final class IntegrationLauncher {
BackgroundLog.Started launched = BackgroundLog.start(pb);
Process p = launched.process();
Path output = launched.log();
+ // so it can be restarted again
+ keepCommand(p, cmd);
long deadline = System.currentTimeMillis() + STARTUP_TIMEOUT_MS;
while (System.currentTimeMillis() < deadline && p.isAlive()) {
if (findByPid(p.pid()) != null && started(p.pid())) {
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/process/CamelContextStatus.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/process/CamelContextStatus.java
index 3677f21a0e37..0eab6982a6e5 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/process/CamelContextStatus.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/process/CamelContextStatus.java
@@ -243,7 +243,7 @@ public class CamelContextStatus extends ProcessWatchCommand
{
String answer = runtime != null ? runtime.getString("platform") : null;
if ("Camel".equals(answer)) {
// generic camel, we need to check if we run in JBang
- String cl = ph.info().commandLine().orElse("");
+ String cl = ProcessHelper.commandLineText(ph);
if (cl.contains("main.CamelJBang run")) {
answer = "JBang";
}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/process/CamelContextTop.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/process/CamelContextTop.java
index 4896e0fec0ff..2cd8beb0dd75 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/process/CamelContextTop.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/process/CamelContextTop.java
@@ -208,7 +208,7 @@ public class CamelContextTop extends ProcessWatchCommand {
String answer = runtime != null ? runtime.getString("platform") : null;
if ("Camel".equals(answer)) {
// generic camel, we need to check if we run in JBang
- String cl = ph.info().commandLine().orElse("");
+ String cl = ProcessHelper.commandLineText(ph);
if (cl.contains("main.CamelJBang run")) {
answer = "JBang";
}
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 5a6359a5539c..b2158cf04554 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
@@ -20,9 +20,7 @@ import java.io.File;
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 org.apache.camel.dsl.jbang.core.commands.CamelJBangMain;
import org.apache.camel.dsl.jbang.core.common.BackgroundLog;
@@ -31,6 +29,7 @@ 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.ProcessHelper;
import org.apache.camel.dsl.jbang.core.common.TimeoutConverter;
import org.apache.camel.util.TimeUtils;
import org.apache.camel.util.json.JsonObject;
@@ -71,10 +70,13 @@ public class RestartProcess extends ProcessBaseCommand {
.orElseThrow(() -> CliException.notFound("No running Camel
integration matches: " + name));
String displayName = process.name();
- // capture command line before stopping
- Optional<String> cmdOpt = ph.info().command();
- Optional<String[]> argsOpt = ph.info().arguments();
- Optional<String> cmdLineOpt = ph.info().commandLine();
+ // capture the command line before stopping: all of it, also where the
JDK cuts a long one short (Linux)
+ List<String> cmd = ProcessHelper.commandLine(ph);
+ if (cmd == null) {
+ // a command line in one text, unless it was cut short:
relaunched, its classpath would be broken
+ String commandLine = ph.info().commandLine().orElse("");
+ cmd = ProcessHelper.isCut(commandLine) ? List.of() :
parseCommandLine(commandLine);
+ }
// get working directory from status file
String directory = null;
@@ -83,15 +85,6 @@ public class RestartProcess extends ProcessBaseCommand {
directory = runtime.getString("directory");
}
- // build the command to re-launch
- List<String> cmd = new ArrayList<>();
- if (cmdOpt.isPresent() && argsOpt.isPresent() && argsOpt.get().length
> 0) {
- cmd.add(cmdOpt.get());
- Collections.addAll(cmd, argsOpt.get());
- } else if (cmdLineOpt.isPresent()) {
- cmd.addAll(parseCommandLine(cmdLineOpt.get()));
- }
-
if (cmd.isEmpty()) {
throw CliException.failure("Cannot restart: command line not
available for PID " + pid);
}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/ProcessHelper.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/ProcessHelper.java
index 3286111648d5..c5ea0c2915cf 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/ProcessHelper.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/ProcessHelper.java
@@ -16,7 +16,15 @@
*/
package org.apache.camel.dsl.jbang.core.common;
+import java.io.IOException;
+import java.nio.charset.Charset;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
import java.util.StringJoiner;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -30,9 +38,133 @@ public final class ProcessHelper {
private static final String[] DSL_EXT = new String[] { "java", "xml",
"yaml" };
private static final Pattern PATTERN = Pattern.compile("([\\w|\\-.])+");
+ /**
+ * The smallest size of a page: on Linux the JDK reads one page of
<tt>/proc/<pid>/cmdline</tt>, and cuts a
+ * longer command line at that size.
+ */
+ private static final int PAGE_SIZE = 4096;
+
+ /** Where the command line of a process is read on Linux. */
+ private static volatile Path procRoot = Path.of("/proc");
+
private ProcessHelper() {
}
+ /**
+ * Reads the command line of a process from
<tt><procRoot>/<pid>/cmdline</tt> instead of
+ * <tt>/proc/<pid>/cmdline</tt>. Unit tests may set this to give a
process the command line they need.
+ *
+ * @param procRoot the directory, or null for <tt>/proc</tt>
+ */
+ public static void useProcRoot(Path procRoot) {
+ ProcessHelper.procRoot = procRoot != null ? procRoot :
Path.of("/proc");
+ }
+
+ /**
+ * The command line of a process: its command (the executable) and its
arguments, all of them, or null when they are
+ * not known.
+ * <p/>
+ * The JDK gives the arguments of a process ({@link
ProcessHandle.Info#arguments()}) only when it has read all of
+ * them. On Linux it reads one page (4 KB) of
<tt>/proc/<pid>/cmdline</tt>: a longer command line, such as the
+ * one of camel run started through jbang with its classpath of about 15
KB, has no arguments, and its
+ * {@link ProcessHandle.Info#commandLine()} is cut short (CAMEL-26397).
The arguments are then read from that file,
+ * which has all of them. The JDK gives no arguments either of a process
that has an empty one, and none at all on
+ * Windows, which has no such file.
+ *
+ * @param ph the process
+ * @return the command and its arguments, or null
+ */
+ public static List<String> commandLine(ProcessHandle ph) {
+ return ph != null ? commandLine(ph, ph.info()) : null;
+ }
+
+ private static List<String> commandLine(ProcessHandle ph,
ProcessHandle.Info info) {
+ Optional<String> command = info.command();
+ Optional<String[]> arguments = info.arguments();
+ if (command.isPresent() && arguments.isPresent()) {
+ List<String> answer = new ArrayList<>();
+ answer.add(command.get());
+ Collections.addAll(answer, arguments.get());
+ return answer;
+ }
+ if (command.isEmpty() && info.commandLine().isEmpty()) {
+ // the JDK could read nothing of its command line (such as of a
process that has ended), nor can this
+ return null;
+ }
+ List<String> answer = readCommandLine(ph.pid());
+ if (answer != null && command.isPresent()) {
+ // the executable the JDK gives (/proc/<pid>/exe), as it does with
the arguments when it has them all
+ answer.set(0, command.get());
+ }
+ return answer;
+ }
+
+ /**
+ * The command line of a process as one text, its command and arguments
separated by a space:
+ * {@link ProcessHandle.Info#commandLine()}, or all of it where the JDK
cuts it short (see
+ * {@link #commandLine(ProcessHandle)}); empty when it is not known.
+ *
+ * @param ph the process
+ * @return the command line, or empty
+ */
+ public static String commandLineText(ProcessHandle ph) {
+ if (ph == null) {
+ return "";
+ }
+ ProcessHandle.Info info = ph.info();
+ String text = info.commandLine().orElse("");
+ if (!text.isEmpty() && info.arguments().isEmpty()) {
+ // a command line without its arguments: on Linux one longer than
a page, cut short
+ List<String> answer = commandLine(ph, info);
+ if (answer != null) {
+ return String.join(" ", answer);
+ }
+ return isCut(text) ? "" : text;
+ }
+ return text;
+ }
+
+ /**
+ * Whether a command line that {@link ProcessHandle.Info#commandLine()}
gives was cut short: on Linux the JDK cuts
+ * one longer than a page at the size of the page (4 KB, or 16 or 64 KB
where the pages are larger), and the end of
+ * what is left is the middle of an argument, mostly of the classpath.
+ *
+ * @param commandLine the command line
+ * @return true when it is as long as a page
+ */
+ public static boolean isCut(String commandLine) {
+ int length = commandLine.length();
+ return length >= PAGE_SIZE && Integer.bitCount(length) == 1;
+ }
+
+ /**
+ * The command line in <tt>/proc/<pid>/cmdline</tt>: the command and
the arguments, each one ending with a NUL
+ * byte. Null when there is no such file (another OS than Linux, or a
process that has ended) or it is empty.
+ */
+ static List<String> readCommandLine(long pid) {
+ byte[] bytes;
+ try {
+ bytes =
Files.readAllBytes(procRoot.resolve(Long.toString(pid)).resolve("cmdline"));
+ } catch (IOException | RuntimeException e) {
+ return null;
+ }
+ // decoded as the JDK decodes the arguments of a process and encodes
those of the process it starts
+ Charset charset =
Charset.forName(System.getProperty("native.encoding", ""),
Charset.defaultCharset());
+ List<String> answer = new ArrayList<>();
+ int start = 0;
+ for (int i = 0; i < bytes.length; i++) {
+ if (bytes[i] == 0) {
+ answer.add(new String(bytes, start, i - start, charset));
+ start = i + 1;
+ }
+ }
+ if (start < bytes.length) {
+ // the last one without its NUL byte, as a process that changed
its arguments may leave it
+ answer.add(new String(bytes, start, bytes.length - start,
charset));
+ }
+ return answer.isEmpty() ? null : answer;
+ }
+
public static String extractName(JsonObject root, ProcessHandle ph) {
String name = doExtractName(root, ph);
return FileUtil.stripPath(name);
@@ -46,7 +178,7 @@ public final class ProcessHelper {
return mc;
}
}
- String cl = ph != null ? ph.info().commandLine().orElse("") : "";
+ String cl = commandLineText(ph);
// this may be a maven plugin run that spawns a child process where
Camel actually runs (link to child)
String mvn = extractMavenPluginName(cl);
@@ -68,7 +200,7 @@ public final class ProcessHelper {
mvn = extractMavenPluginName(cl);
if (mvn == null && ph != null && ph.parent().isPresent()) {
// try parent as it may spawn a sub process
- String clp = ph.parent().get().info().commandLine().orElse("");
+ String clp = commandLineText(ph.parent().get());
mvn = extractMavenPluginName(clp);
}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/IntegrationLauncherCommandLineTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/IntegrationLauncherCommandLineTest.java
new file mode 100644
index 000000000000..8f46ace52401
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/IntegrationLauncherCommandLineTest.java
@@ -0,0 +1,190 @@
+/*
+ * 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.commands.ai;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Supplier;
+import java.util.stream.Stream;
+
+import org.apache.camel.dsl.jbang.core.common.CommandLineHelper;
+import org.apache.camel.dsl.jbang.core.common.ProcessHelper;
+import org.apache.camel.dsl.jbang.core.common.RuntimeHelper;
+import org.apache.camel.util.json.JsonObject;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+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;
+
+/**
+ * CAMEL-26397: the AI tools read the whole command line of an integration,
also when the JDK gives none of its
+ * arguments: on Linux the JDK reads one page (4 KB) of
/proc/<pid>/cmdline, and camel run started through jbang
+ * has a longer command line with its classpath alone; on Windows it gives no
arguments of any process. The integrations
+ * here have an empty argument, of which the JDK gives no arguments on any OS,
and their /proc/<pid>/cmdline is in
+ * a directory of the test.
+ */
+@DisabledOnOs(value = OS.WINDOWS, disabledReason = "the integrations are shell
scripts")
+class IntegrationLauncherCommandLineTest {
+
+ // an integration: it writes its arguments, its pid file and its status
file, as the CLI connector does, and runs
+ // until its pid file is removed, as camel stop does
+ private static final String RUNNING = "d=DIR; echo \"$@\" >
\"$d/$$-args\"; touch \"$d/$$\";"
+ + " echo
'{\"context\":{\"name\":\"orders\",\"phase\":5,\"state\":\"Started\"}}'"
+ + " > \"$d/$$-status.json\";"
+ + " while [ -f \"$d/$$\" ]; do sleep
0.1; done; rm -f \"$d/$$-status.json\"";
+
+ @TempDir
+ Path home;
+ @TempDir
+ Path project;
+ @TempDir
+ Path proc;
+
+ private Path previousHome;
+ private Supplier<List<String>> previousCommand;
+ private Path camelDir;
+ private final List<Process> started = new ArrayList<>();
+
+ @BeforeEach
+ void useHome() throws IOException {
+ previousHome = CommandLineHelper.getHomeDir();
+ CommandLineHelper.useHomeDir(home.toString());
+ camelDir = CommandLineHelper.getCamelDir();
+ Files.createDirectories(camelDir);
+ previousCommand = IntegrationLauncher.camelCommand;
+ // no /proc/<pid>/cmdline, as on macOS and Windows, unless a test
writes one
+ ProcessHelper.useProcRoot(proc);
+ }
+
+ @AfterEach
+ void restore() throws IOException {
+ started.forEach(Process::destroyForcibly);
+ // the integrations the launcher started have a pid file
+ try (Stream<Path> files = Files.list(camelDir)) {
+ files.map(f -> f.getFileName().toString()).filter(n ->
n.matches("\\d+"))
+ .forEach(n ->
ProcessHandle.of(Long.parseLong(n)).ifPresent(ProcessHandle::destroyForcibly));
+ }
+ ProcessHelper.useProcRoot(null);
+ IntegrationLauncher.camelCommand = previousCommand;
+ CommandLineHelper.useHomeDir(previousHome.toString());
+ }
+
+ @Test
+ void
anIntegrationThatCamelRunStartedIsRestartedWithTheCommandItWasStartedWith() {
+ String body = RUNNING.replace("DIR", "'" + camelDir + "'");
+ IntegrationLauncher.camelCommand = () -> List.of("sh", "-c", body,
"sh");
+ JsonObject result = IntegrationLauncher.run(project,
List.of("orders.camel.yaml"), "orders", false, List.of(""));
+ assertThat(result.getString("status")).isEqualTo("started");
+ long pid = result.getLong("pid");
+
+ long restarted = restarted(IntegrationLauncher.restart(pid));
+
+ assertThat(restarted).isNotEqualTo(pid);
+ assertThat(camelDir.resolve(restarted +
"-args")).content().startsWith("run orders.camel.yaml --name=orders");
+ // and again: the command of the process the restart started is kept
as well
+ long again = restarted(IntegrationLauncher.restart(restarted));
+ assertThat(camelDir.resolve(again +
"-args")).content().startsWith("run orders.camel.yaml --name=orders");
+ }
+
+ @Test
+ void anIntegrationStartedElsewhereIsRestartedWithItsWholeCommandLine()
throws Exception {
+ String body = RUNNING.replace("DIR", "'" + camelDir + "'");
+ long pid = integration(List.of("sh", "-c", body, "sh", "run",
"orders.camel.yaml", "--name=orders", ""));
+ await().atMost(Duration.ofSeconds(10)).until(() ->
IntegrationLauncher.started(pid));
+
+ long restarted = restarted(IntegrationLauncher.restart(pid));
+
+ assertThat(camelDir.resolve(restarted +
"-args")).content().startsWith("run orders.camel.yaml --name=orders");
+ }
+
+ @Test
+ void anIntegrationInDevModeThatWatchesItsDirectory() throws Exception {
+ long pid = runningFromTheProject("run", "--source-dir=.", "--dev", "");
+
+
assertThat(IntegrationLauncher.devModeFrom(project)).extracting(RuntimeHelper.ProcessInfo::pid).isEqualTo(pid);
+ assertThat(IntegrationLauncher.watchesFile(pid,
"templates/letter.ftl")).isTrue();
+ assertThat(alreadyRunning()).contains("In dev mode a changed or added
file is reloaded");
+ }
+
+ @Test
+ void anIntegrationInDevModeThatWatchesItsOwnFiles() throws Exception {
+ long pid = runningFromTheProject("run", "orders.camel.yaml", "--dev",
"");
+
+
assertThat(IntegrationLauncher.devModeFrom(project)).extracting(RuntimeHelper.ProcessInfo::pid).isEqualTo(pid);
+ assertThat(IntegrationLauncher.watchesFile(pid,
"orders.camel.yaml")).isTrue();
+ assertThat(IntegrationLauncher.watchesFile(pid,
"templates/letter.ftl")).isFalse();
+ assertThat(alreadyRunning()).contains("It was started with its own
files");
+ }
+
+ @Test
+ void anIntegrationNotInDevMode() throws Exception {
+ runningFromTheProject("run", "--source-dir=.", "");
+
+ assertThat(IntegrationLauncher.devModeFrom(project)).isNull();
+ assertThat(alreadyRunning()).contains("It is not in dev mode");
+ }
+
+ // an integration that this JVM did not start, running from the project
directory with these arguments
+ private long runningFromTheProject(String... args) throws Exception {
+ List<String> cmd = new ArrayList<>(List.of("sh", "-c", "sleep 60; exit
0", "sh"));
+ cmd.addAll(List.of(args));
+ long pid = integration(cmd);
+ // its status file, as the CLI connector writes it, with its working
directory
+ JsonObject runtime = new JsonObject();
+ runtime.put("directory", project.toRealPath().toString());
+ JsonObject context = new JsonObject();
+ context.put("name", "orders");
+ context.put("phase", 5);
+ context.put("state", "Started");
+ JsonObject status = new JsonObject();
+ status.put("runtime", runtime);
+ status.put("context", context);
+ Files.writeString(camelDir.resolve(pid + "-status.json"),
status.toJson());
+ return pid;
+ }
+
+ // starts the command, and puts its command line where Linux has it:
/proc/<pid>/cmdline, each argument ending
+ // with a NUL byte
+ private long integration(List<String> cmd) throws IOException {
+ Process p = new ProcessBuilder(cmd).start();
+ started.add(p);
+ Path dir =
Files.createDirectories(proc.resolve(Long.toString(p.pid())));
+ Files.writeString(dir.resolve("cmdline"), String.join("\0", cmd) +
"\0");
+ return p.pid();
+ }
+
+ private String alreadyRunning() {
+ JsonObject result = new JsonObject();
+ assertThat(IntegrationLauncher.alreadyRunning(project,
result)).isTrue();
+ return result.getString("message");
+ }
+
+ // the pid of the process a restart started
+ private static long restarted(String answer) {
+ assertThat(answer).startsWith("Restarted orders as pid ");
+ return Long.parseLong(answer.substring(answer.lastIndexOf(' ') + 1));
+ }
+}
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 0082dea3c7ed..e940eb5e8184 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
@@ -20,6 +20,7 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Optional;
+import java.util.function.Consumer;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;
@@ -29,6 +30,7 @@ import org.apache.camel.dsl.jbang.core.common.BackgroundLog;
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.ProcessHelper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.DisabledOnOs;
import org.junit.jupiter.api.condition.OS;
@@ -146,6 +148,71 @@ class RestartProcessTest extends ProcessCommandTestSupport
{
assertTrue(printer.getStderr().contains("boom"), printer.getStderr());
}
+ /**
+ * CAMEL-26397: on Linux the JDK gives no arguments of a process whose
command line is longer than a page (4 KB),
+ * such as camel run started through jbang, and its command line cut
short. The integration is restarted with the
+ * whole command line, from /proc/<pid>/cmdline.
+ */
+ @Test
+ @DisabledOnOs(OS.WINDOWS)
+ void testRestartRelaunchesTheWholeCommandLine(@TempDir Path proc) throws
Exception {
+ String script = "echo '{\"context\":{\"name\":\"myApp\",\"phase\":5}}'
> '"
+ + CommandLineHelper.getCamelDir().toAbsolutePath() +
"'/$$-status.json; echo started; exec sleep 60";
+ // as long as a classpath
+ String longArgument = "/m2/a.jar:".repeat(500);
+ Path dir =
Files.createDirectories(proc.resolve(Long.toString(TEST_PID)));
+ Files.writeString(dir.resolve("cmdline"), String.join("\0", "sh",
"-c", script, "sh", longArgument) + "\0");
+ ProcessHelper.useProcRoot(proc);
+
+ int exit;
+ try {
+ exit = restart(cutShort("/bin/sh", "/bin/sh -c " + script + " sh "
+ longArgument));
+ } finally {
+ ProcessHelper.useProcRoot(null);
+ stopTheRelaunchedIntegration();
+ }
+
+ assertEquals(0, exit);
+ assertTrue(printer.getOutput().contains("Restarted Camel integration:
myApp (PID: "), printer.getOutput());
+ }
+
+ @Test
+ void testCommandLineCutShortIsNotRelaunched(@TempDir Path proc) throws
Exception {
+ writeStatusFile(TEST_PID, buildContextStatus("myApp", 5));
+ // no /proc/<pid>/cmdline
+ ProcessHelper.useProcRoot(proc);
+
+ 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));
+ cutShort("/opt/java/bin/java", "/opt/java/bin/java -classpath " +
"/m2/a.jar:".repeat(500) + " Main")
+ .accept(ph);
+
+ CliException e = assertThrows(CliException.class, command::doCall);
+
+ // relaunched, its classpath would be cut short
+ assertEquals("Cannot restart: command line not available for PID "
+ TEST_PID, e.getMessage());
+ verify(ph, never()).destroy();
+ } finally {
+ ProcessHelper.useProcRoot(null);
+ }
+ }
+
+ // the JDK has no arguments of the process, and the first page (4 KB) of
its command line
+ private static Consumer<ProcessHandle> cutShort(String command, String
commandLine) {
+ return ph -> {
+ when(ph.info().command()).thenReturn(Optional.of(command));
+ when(ph.info().arguments()).thenReturn(Optional.empty());
+
when(ph.info().commandLine()).thenReturn(Optional.of(commandLine.substring(0,
4096)));
+ };
+ }
+
@Test
void testCommandLineOfTheIntegration() {
assertEquals(List.of("java", "-jar", "app.jar"),
RestartProcess.parseCommandLine("java -jar app.jar"));
@@ -165,6 +232,14 @@ class RestartProcessTest extends ProcessCommandTestSupport
{
// restarts myApp, which runs the given command
private int restart(String command, String... args) throws Exception {
+ return restart(ph -> {
+ when(ph.info().command()).thenReturn(Optional.of(command));
+ when(ph.info().arguments()).thenReturn(Optional.of(args));
+ });
+ }
+
+ // restarts myApp, whose command line the JDK gives as stubbed
+ private int restart(Consumer<ProcessHandle> commandLine) throws Exception {
writeStatusFile(TEST_PID, buildContextStatus("myApp", 5));
RestartProcess restart = new RestartProcess(new
CamelJBangMain().withPrinter(printer));
restart.name = "myApp";
@@ -177,8 +252,7 @@ class RestartProcessTest extends ProcessCommandTestSupport {
mocked.when(ProcessHandle::allProcesses).thenAnswer(inv ->
Stream.of(ph));
// found, and gone once it is stopped
mocked.when(() ->
ProcessHandle.of(TEST_PID)).thenReturn(Optional.of(ph), Optional.empty());
- when(ph.info().command()).thenReturn(Optional.of(command));
- when(ph.info().arguments()).thenReturn(Optional.of(args));
+ commandLine.accept(ph);
int exit = restart.doCall();
// there is no pid file of camel run to delete, so the integration
is asked to stop by its process
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/ProcessHelperTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/ProcessHelperTest.java
index 0c903306ca6c..e5da42955086 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/ProcessHelperTest.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/ProcessHelperTest.java
@@ -16,14 +16,26 @@
*/
package org.apache.camel.dsl.jbang.core.common;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Stream;
import org.apache.camel.util.json.JsonObject;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledOnOs;
+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;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@@ -35,6 +47,25 @@ class ProcessHelperTest {
private static final String MAVEN
= "/usr/bin/java -classpath plexus-classworlds-2.8.0.jar
org.codehaus.plexus.classworlds.launcher.Launcher";
+ // the pid of the processes whose command line is in the proc directory of
the test
+ private static final long PID = 4242;
+
+ // a classpath as long as the one of camel run started through jbang
(about 15 KB)
+ private static final String CLASSPATH = classpath();
+
+ @TempDir
+ Path proc;
+
+ @BeforeEach
+ void useProc() {
+ ProcessHelper.useProcRoot(proc);
+ }
+
+ @AfterEach
+ void restoreProc() {
+ ProcessHelper.useProcRoot(null);
+ }
+
@Test
void mainClassOfTheIntegration() {
JsonObject root = root("camel-1");
@@ -86,6 +117,136 @@ class ProcessHelperTest {
assertThat(ProcessHelper.extractName(null,
app)).isEqualTo("quarkus:dev");
}
+ /**
+ * CAMEL-26397: on Linux the JDK reads one page (4 KB) of
/proc/<pid>/cmdline, so a longer command line has no
+ * arguments, and the command line it gives is cut short. The whole
command line is read from that file then.
+ */
+ @Test
+ void theWholeCommandLineOfAProcessWhoseCommandLineTheJdkCutShort() throws
Exception {
+ String full = "/opt/java/bin/java -classpath " + CLASSPATH + "
main.CamelJBang run hello.camel.yaml --dev";
+ ProcessHandle ph = cutShort(full);
+ cmdline(PID, "java", "-classpath", CLASSPATH, "main.CamelJBang",
"run", "hello.camel.yaml", "--dev");
+
+ // the command is the executable the JDK gives, as for a command line
it has all of
+ assertThat(ProcessHelper.commandLine(ph))
+ .containsExactly("/opt/java/bin/java", "-classpath",
CLASSPATH, "main.CamelJBang", "run",
+ "hello.camel.yaml", "--dev");
+ assertThat(ProcessHelper.commandLineText(ph)).isEqualTo(full);
+ // the route files after main.CamelJBang run, which the cut command
line does not reach
+ assertThat(ProcessHelper.extractName(root("CamelJBang"),
ph)).isEqualTo("hello.camel.yaml");
+ }
+
+ @Test
+ void theCommandLineTheJdkHasAllOf() throws Exception {
+ ProcessHandle.Info info = mock(ProcessHandle.Info.class);
+ when(info.command()).thenReturn(Optional.of("/usr/bin/java"));
+ when(info.arguments()).thenReturn(Optional.of(new String[] { "-jar",
"app.jar" }));
+ ProcessHandle ph = mock(ProcessHandle.class);
+ when(ph.info()).thenReturn(info);
+ // not read
+ cmdline(PID, "other");
+
+
assertThat(ProcessHelper.commandLine(ph)).containsExactly("/usr/bin/java",
"-jar", "app.jar");
+ }
+
+ @Test
+ void theArgumentsAreSeparatedByANulByte() throws Exception {
+ ProcessHandle ph = withoutArguments("/bin/sh", "sh -c true");
+ // an empty argument (the JDK gives no arguments of a process that has
one), and a last one without its NUL
+ Path dir = Files.createDirectories(proc.resolve(Long.toString(PID)));
+ Files.write(dir.resolve("cmdline"),
"sh\0-c\0\0true".getBytes(StandardCharsets.UTF_8));
+
+ assertThat(ProcessHelper.commandLine(ph)).containsExactly("/bin/sh",
"-c", "", "true");
+ }
+
+ @Test
+ void aCommandLineCutShortIsNotUsed() throws Exception {
+ String full = "/opt/java/bin/java -classpath " + CLASSPATH + "
main.CamelJBang run hello.camel.yaml";
+ ProcessHandle ph = cutShort(full);
+
+ // no /proc/<pid>/cmdline: the process has ended, or another OS than
Linux
+ assertThat(ProcessHelper.commandLine(ph)).isNull();
+ assertThat(ProcessHelper.commandLineText(ph)).isEmpty();
+ // the end of a cut command line is the middle of the classpath, not a
name
+ assertThat(ProcessHelper.extractName(root("orders"),
ph)).isEqualTo("orders");
+
+ // an empty file
+ cmdline(PID);
+ assertThat(ProcessHelper.commandLine(ph)).isNull();
+ }
+
+ @Test
+ void aCommandLineIsCutShortAtTheSizeOfAPage() {
+ assertThat(ProcessHelper.isCut("x".repeat(4096))).isTrue();
+ assertThat(ProcessHelper.isCut("x".repeat(16384))).isTrue();
+ assertThat(ProcessHelper.isCut("x".repeat(4095))).isFalse();
+ assertThat(ProcessHelper.isCut("x".repeat(4097))).isFalse();
+ assertThat(ProcessHelper.isCut("x".repeat(6000))).isFalse();
+ }
+
+ @Test
+ void nothingIsReadOfAProcessTheJdkKnowsNothingOf() throws Exception {
+ ProcessHandle ph = process(null, null);
+ when(ph.pid()).thenReturn(PID);
+ cmdline(PID, "java", "-jar", "app.jar");
+
+ assertThat(ProcessHelper.commandLine(ph)).isNull();
+ }
+
+ /** CAMEL-26397: a process with a command line longer than a page, as the
JDK and the system give it. */
+ @Test
+ @EnabledOnOs(OS.LINUX)
+ void theWholeCommandLineOfAProcessOnLinux() throws Exception {
+ ProcessHelper.useProcRoot(null);
+ String longArgument = "x".repeat(5000);
+ // two commands, so the shell does not replace itself with sleep
+ Process p = new ProcessBuilder("sh", "-c", "sleep 60; exit 0", "sh",
longArgument).start();
+ try {
+ ProcessHandle ph = p.toHandle();
+ await().atMost(Duration.ofSeconds(10)).untilAsserted(() ->
assertThat(ProcessHelper.commandLine(ph))
+ .containsExactly(ph.info().command().orElse("sh"), "-c",
"sleep 60; exit 0", "sh", longArgument));
+ } finally {
+ p.destroyForcibly();
+ }
+ }
+
+ // a java process whose command line the JDK cut short: no arguments, and
the first page of the command line
+ private static ProcessHandle cutShort(String commandLine) {
+ return withoutArguments("/opt/java/bin/java", commandLine.substring(0,
4096));
+ }
+
+ // a process the JDK gives the command and the command line of, but no
arguments
+ private static ProcessHandle withoutArguments(String command, String
commandLine) {
+ ProcessHandle.Info info = mock(ProcessHandle.Info.class);
+ when(info.command()).thenReturn(Optional.of(command));
+ when(info.arguments()).thenReturn(Optional.empty());
+ when(info.commandLine()).thenReturn(Optional.of(commandLine));
+ ProcessHandle ph = mock(ProcessHandle.class);
+ when(ph.pid()).thenReturn(PID);
+ when(ph.info()).thenReturn(info);
+ when(ph.parent()).thenReturn(Optional.empty());
+ when(ph.children()).thenAnswer(inv -> Stream.empty());
+ return ph;
+ }
+
+ // the command line of the process in the proc directory, as
/proc/<pid>/cmdline has it
+ private void cmdline(long pid, String... args) throws Exception {
+ StringBuilder sb = new StringBuilder();
+ for (String arg : args) {
+ sb.append(arg).append('\0');
+ }
+ Path dir = Files.createDirectories(proc.resolve(Long.toString(pid)));
+ Files.writeString(dir.resolve("cmdline"), sb);
+ }
+
+ private static String classpath() {
+ List<String> jars = new ArrayList<>();
+ for (int i = 0; i < 160; i++) {
+
jars.add("/home/user/.m2/repository/org/example/some-dependency/1.2." + i +
"/some-dependency-1.2." + i + ".jar");
+ }
+ return String.join(":", jars);
+ }
+
private static JsonObject root(String contextName) {
JsonObject root = new JsonObject();
root.put("context", new JsonObject(Map.of("name", contextName)));