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 b7e00e8bbcef CAMEL-25364: camel-jbang - a failed run or restart 
returns its cause, and camel_run does not start a second copy of a running app 
(#27399)
b7e00e8bbcef is described below

commit b7e00e8bbcefc755efbbd3238d7ec51923569dd7
Author: Claus Ibsen <[email protected]>
AuthorDate: Tue Oct 6 12:52:03 2026 +0200

    CAMEL-25364: camel-jbang - a failed run or restart returns its cause, and 
camel_run does not start a second copy of a running app (#27399)
    
    * CAMEL-25364: camel-jbang - a failed run or restart returns its cause, and 
camel_run does not start a second copy of a running app
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
    Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m
    
    * CAMEL-25364: address review feedback
    
    - the failure output keeps the exception lines of a record (the exception, 
its causes and a
      message over several lines, such as the diagnostics of a Java class that 
does not compile) and
      only leaves out the stack frames; output without log records is returned 
as it is, its end kept
    - the console output is decoded leniently, so a byte that is not UTF-8 does 
not empty it
    - the guard finds the running integration through a symlinked directory 
(Files.isSameFile), and
      also a copy this launcher started that is still starting, or started by a 
concurrent call
    - the guard says what dev mode and camel_control restart do by how the 
integration was started:
      with --source-dir and --dev, without dev mode, or with its own files
    - the running result has the log file
    - docs: the camel_run row; tests for the guard (also through a symlink) and 
the output
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
    Signed-off-by: Claus Ibsen <[email protected]>
    
    ---------
    
    Signed-off-by: Claus Ibsen <[email protected]>
    Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
---
 .../modules/ROOT/pages/camel-jbang-mcp.adoc        |   8 +-
 .../core/commands/ai/IntegrationLauncher.java      | 207 +++++++++++++++++++--
 .../commands/ai/IntegrationLauncherGuardTest.java  |  91 +++++++++
 .../core/commands/ai/IntegrationLauncherTest.java  |  95 ++++++++++
 4 files changed, 383 insertions(+), 18 deletions(-)

diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc
index 609b1a3658bd..0346a95592a3 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc
@@ -352,7 +352,13 @@ project `directory` as an argument, the runtime tools take 
the integration `name
 | Starts an integration from a project directory with `camel run --source-dir` 
in a separate process, in dev
   mode by default: the directory is watched, so a changed or added file (a 
route, a bean file, a Java class) is
   reloaded. Name files to run only those, for a directory that holds several 
apps. Returns the pid, name and
-  log file once the integration is up.
+  log file once the integration is up. When an integration already runs from 
the directory, or one started
+  earlier is still starting, no second copy is started: the answer is that one 
(`status: running` with its
+  pid, name and log file, or `status: starting`), with what dev mode and 
`camel_control restart` do for it.
+  When the start fails, the answer has the cause from the console output: each 
log record with its exception
+  lines (also a message over several lines, such as the diagnostics of a Java 
class that does not compile) and
+  the first line of the user's code, without the stack frames. A failed 
`camel_control restart` answers the
+  same way.
 
 | `camel_control`
 | Controls a running integration: `stop`, `kill`, `restart` (picks up edited 
files without dev mode),
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 7d64a6322d95..ef81b0aff447 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,14 +21,19 @@ 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;
+import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.TimeUnit;
+import java.util.regex.Pattern;
 
 import org.apache.camel.dsl.jbang.core.common.CommandLineHelper;
 import org.apache.camel.dsl.jbang.core.common.LauncherHelper;
 import org.apache.camel.dsl.jbang.core.common.RuntimeHelper;
+import org.apache.camel.util.json.JsonArray;
 import org.apache.camel.util.json.JsonObject;
 
 /**
@@ -41,6 +46,18 @@ public final class IntegrationLauncher {
     /** How long a started integration is given to publish its status file 
before the call returns without a pid. */
     static final long STARTUP_TIMEOUT_MS = 30_000;
 
+    /** The last log records of a failed start that are returned. */
+    static final int FAILURE_RECORDS = 15;
+
+    /** A stack frame, or the count of the frames a cause shares with the 
exception it caused. */
+    private static final Pattern FRAME = Pattern.compile("^\\s*(at 
\\S.*|\\.\\.\\. \\d+ (more|common frames omitted))$");
+
+    /**
+     * The processes this JVM started from a directory with --source-dir, so a 
copy that has not written its status file
+     * yet (still starting, or started by a concurrent call) is seen by the 
guard too.
+     */
+    private static final Map<Path, ProcessHandle> LAUNCHED = new 
ConcurrentHashMap<>();
+
     private IntegrationLauncher() {
     }
 
@@ -72,22 +89,33 @@ public final class IntegrationLauncher {
             return result;
         }
         result.put("directory", directory.toString());
-        result.put("command", String.join(" ", cmd));
         Path output;
         Process process;
-        try {
-            Files.createDirectories(CommandLineHelper.getCamelDir());
-            output = Files.createTempFile(CommandLineHelper.getCamelDir(), 
"camel-launch-", ".log");
-            output.toFile().deleteOnExit();
-            ProcessBuilder pb = new ProcessBuilder(cmd);
-            pb.directory(directory.toFile());
-            pb.redirectErrorStream(true);
-            pb.redirectOutput(output.toFile());
-            process = pb.start();
-        } catch (IOException e) {
-            result.put("status", "failed");
-            result.put("error", "Cannot start camel run: " + e.getMessage());
-            return result;
+        // one check and start at a time, so two calls in flight do not both 
start a copy
+        synchronized (LAUNCHED) {
+            if (sourceDir && alreadyRunning(directory, result)) {
+                // the whole directory is the app: one already running from it 
is that app, and a second copy only
+                // fights it for ports and files and makes its name ambiguous 
to the other tools
+                return result;
+            }
+            result.put("command", String.join(" ", cmd));
+            try {
+                Files.createDirectories(CommandLineHelper.getCamelDir());
+                output = Files.createTempFile(CommandLineHelper.getCamelDir(), 
"camel-launch-", ".log");
+                output.toFile().deleteOnExit();
+                ProcessBuilder pb = new ProcessBuilder(cmd);
+                pb.directory(directory.toFile());
+                pb.redirectErrorStream(true);
+                pb.redirectOutput(output.toFile());
+                process = pb.start();
+            } catch (IOException e) {
+                result.put("status", "failed");
+                result.put("error", "Cannot start camel run: " + 
e.getMessage());
+                return result;
+            }
+            if (sourceDir) {
+                LAUNCHED.put(key(directory), process.toHandle());
+            }
         }
         long pid = process.pid();
         long deadline = System.currentTimeMillis() + STARTUP_TIMEOUT_MS;
@@ -95,7 +123,7 @@ public final class IntegrationLauncher {
             if (!process.isAlive()) {
                 result.put("status", "failed");
                 result.put("exitCode", process.exitValue());
-                result.put("output", readTail(output));
+                result.put("output", failureOutput(output));
                 result.put("message", "camel run exited before the integration 
started; the output has the reason");
                 return result;
             }
@@ -218,9 +246,153 @@ public final class IntegrationLauncher {
         return null;
     }
 
+    /**
+     * The console output of a run that failed to start, as camel_get_log 
shows a log (CAMEL-25364): each record keeps
+     * its message, the lines of its exceptions (the exception, its causes, 
and a message over several lines such as the
+     * diagnostics of a Java class that does not compile) and the first line 
of the user's code, and the stack frames
+     * are left out. The end of the raw output is mostly frames of the 
runtime, and the line that says what went wrong
+     * is above them. This is the only copy the agent gets, as the console 
capture is deleted. Output without log
+     * records (a launcher that fails before logging) is returned as it is, 
its end kept.
+     */
+    static String failureOutput(Path output) {
+        List<String> lines;
+        try {
+            lines = readText(output).lines().toList();
+        } catch (IOException e) {
+            return readTail(output).strip();
+        }
+        if (lines.size() > 5000) {
+            lines = lines.subList(lines.size() - 5000, lines.size());
+        }
+        lines = lines.stream().filter(l -> !l.isBlank()).toList();
+        // folded for the first line of the user's code, and once more without 
the frames for the exception lines
+        JsonArray rows = (JsonArray) LogFileReader.build(lines, 
FAILURE_RECORDS, null, null, false, new JsonObject())
+                .get("lines");
+        if (rows == null || rows.isEmpty()
+                || rows.stream().allMatch(r -> ((JsonObject) 
r).getStringOrDefault("time", "").isEmpty())) {
+            // no log records: a launcher that failed before logging prints 
its error at the end
+            return readTail(output).strip();
+        }
+        List<String> withoutFrames = lines.stream().filter(l -> 
!FRAME.matcher(l).matches()).toList();
+        JsonArray exceptions = (JsonArray) LogFileReader
+                .build(withoutFrames, FAILURE_RECORDS, null, null, true, new 
JsonObject()).get("lines");
+        boolean aligned = exceptions != null && exceptions.size() == 
rows.size();
+        StringBuilder sb = new StringBuilder();
+        // newest first in the log tool; oldest first here, so it reads as the 
console did
+        for (int i = rows.size() - 1; i >= 0; i--) {
+            JsonObject r = (JsonObject) rows.get(i);
+            String level = r.getStringOrDefault("time", "").isEmpty() ? "" : 
r.getStringOrDefault("level", "") + " ";
+            sb.append(level).append(r.getStringOrDefault("message", ""));
+            if (r.get("detailLines") != null) {
+                String detail = aligned ? ((JsonObject) 
exceptions.get(i)).getString("detail") : null;
+                if (detail != null) {
+                    // the exception, its causes and a message over several 
lines (compiler diagnostics)
+                    detail.lines().forEach(l -> sb.append("\n  
").append(l.strip()));
+                } else if (r.get("cause") != null) {
+                    sb.append("\n  cause: ").append(r.getString("cause"));
+                }
+                if (r.get("at") != null) {
+                    sb.append("\n  at: ").append(r.getString("at"));
+                }
+                sb.append("\n  (stack frames left out)");
+            } else if (r.get("detail") != null) {
+                sb.append("\n").append(r.getString("detail"));
+            }
+            sb.append("\n");
+        }
+        return sb.toString().strip();
+    }
+
+    /**
+     * Whether an integration already runs from the directory, or is still 
starting from it, filling in the result for
+     * it: status running or starting, its pid, and what dev mode and a 
restart do for it.
+     */
+    static boolean alreadyRunning(Path directory, JsonObject result) {
+        RuntimeHelper.ProcessInfo running = runningFrom(directory);
+        if (running != null) {
+            String runningName = running.contextName() != null && 
!running.contextName().isBlank()
+                    ? running.contextName() : running.name();
+            result.put("status", "running");
+            result.put("pid", running.pid());
+            result.put("name", runningName);
+            result.put("log", LogFileReader.logFile(running.pid(), 
running.name()).toString());
+            result.put("message", runningMessage(runningName, running.pid()));
+            return true;
+        }
+        ProcessHandle starting = LAUNCHED.get(key(directory));
+        if (starting != null && starting.isAlive()) {
+            result.put("status", "starting");
+            result.put("pid", starting.pid());
+            result.put("message", "pid " + starting.pid() + " started earlier 
from this directory is still starting"
+                                  + " (dependencies may be downloading); a 
second copy was not started."
+                                  + " list_processes shows it once it is up.");
+            return true;
+        }
+        return false;
+    }
+
+    /** 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);
+        if (args == null) {
+            return prefix + " camel_control stop stops it, then camel_run 
starts the directory again.";
+        }
+        boolean sourceDir = args.stream().anyMatch(a -> 
a.startsWith("--source-dir"));
+        boolean dev = args.contains("--dev");
+        if (!sourceDir) {
+            // started with its own files: dev mode watches those only, and a 
restart runs the same files again
+            return prefix + " It was started with its own files, not the whole 
directory, so a file added to the"
+                   + " directory, such as a Java class, is not part of it, 
also not after camel_control restart: stop it"
+                   + " with camel_control stop, then camel_run runs the whole 
directory.";
+        }
+        return prefix + (dev
+                ? " In dev mode a changed or added file is reloaded; 
camel_control restart restarts it, for example"
+                  + " after adding a Java class."
+                : " It is not in dev mode: camel_control restart picks up a 
changed or added file.");
+    }
+
+    /** 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();
+        for (RuntimeHelper.ProcessInfo p : RuntimeHelper.discoverProcesses()) {
+            Path dir = workingDirectory(p.pid());
+            if (dir != null && sameDirectory(dir, wanted)) {
+                return p;
+            }
+        }
+        return null;
+    }
+
+    /**
+     * Whether two paths are the same directory: the process reports its 
symlink-resolved working directory, and the
+     * caller may pass a path through a symlink (every directory under /tmp on 
macOS).
+     */
+    private static boolean sameDirectory(Path a, Path b) {
+        try {
+            return Files.isSameFile(a, b);
+        } catch (IOException e) {
+            return 
a.toAbsolutePath().normalize().equals(b.toAbsolutePath().normalize());
+        }
+    }
+
+    /** The key of a directory in {@link #LAUNCHED}: its real path, the same 
through any symlink. */
+    private static Path key(Path directory) {
+        try {
+            return directory.toRealPath();
+        } catch (IOException e) {
+            return directory.toAbsolutePath().normalize();
+        }
+    }
+
+    /** The console output, decoded leniently: console output is not always 
UTF-8 (Windows uses the code page). */
+    private static String readText(Path output) throws IOException {
+        return new String(Files.readAllBytes(output), StandardCharsets.UTF_8);
+    }
+
     private static String readTail(Path output) {
         try {
-            String text = Files.readString(output, StandardCharsets.UTF_8);
+            String text = readText(output);
             return text.length() > 4000 ? "..." + text.substring(text.length() 
- 4000) : text;
         } catch (IOException e) {
             return "";
@@ -324,7 +496,8 @@ public final class IntegrationLauncher {
             }
             if (!p.isAlive()) {
                 return "Restart of " + (name != null ? name : "pid " + pid) + 
" failed with exit code "
-                       + p.exitValue() + ":\n" + readTail(output);
+                       + p.exitValue() + ", so it is stopped now: fix the 
cause below, then camel_run starts it again.\n"
+                       + failureOutput(output);
             }
             return "Restarting " + (name != null ? name : "pid " + pid) + " as 
pid " + p.pid()
                    + " (still starting)";
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/IntegrationLauncherGuardTest.java
 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/IntegrationLauncherGuardTest.java
new file mode 100644
index 000000000000..ae6049078887
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/IntegrationLauncherGuardTest.java
@@ -0,0 +1,91 @@
+/*
+ * 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.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+
+import org.apache.camel.dsl.jbang.core.common.CommandLineHelper;
+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.io.TempDir;
+import org.junit.jupiter.api.parallel.Isolated;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * CAMEL-25364: camel_run of a directory an integration already runs from 
returns that one instead of starting a second
+ * copy. The running integration is this test's own JVM, made visible by a 
status file that names the directory.
+ */
+@Isolated
+class IntegrationLauncherGuardTest {
+
+    @TempDir
+    Path home;
+
+    @TempDir
+    Path project;
+
+    private String originalHome;
+
+    @BeforeEach
+    void runningFromTheProject() throws Exception {
+        originalHome = CommandLineHelper.getHomeDir().toString();
+        CommandLineHelper.useHomeDir(home.toString());
+        Files.writeString(project.resolve("route.camel.yaml"), "- from:\n    
uri: timer:tick\n    steps: []\n");
+        Path camelDir = CommandLineHelper.getCamelDir();
+        Files.createDirectories(camelDir);
+        JsonObject runtime = new JsonObject();
+        // the process reports its working directory resolved, as new 
File(".").getAbsolutePath() does
+        runtime.put("directory", project.toRealPath().toString());
+        JsonObject context = new JsonObject();
+        context.put("name", "demo");
+        JsonObject status = new JsonObject();
+        status.put("runtime", runtime);
+        status.put("context", context);
+        Files.writeString(camelDir.resolve(ProcessHandle.current().pid() + 
"-status.json"), status.toJson());
+    }
+
+    @AfterEach
+    void restoreHome() {
+        CommandLineHelper.useHomeDir(originalHome);
+    }
+
+    @Test
+    void theRunningIntegrationIsReturned() {
+        JsonObject result = IntegrationLauncher.run(project, List.of(), null, 
true, List.of());
+        assertThat(result.getString("status")).isEqualTo("running");
+        
assertThat(result.getLong("pid")).isEqualTo(ProcessHandle.current().pid());
+        assertThat(result.getString("name")).isEqualTo("demo");
+        assertThat(result.getString("log")).isNotBlank();
+        assertThat(result.containsKey("command")).isFalse();
+        // this JVM was not started with --source-dir, so it is not said to 
reload an added file
+        assertThat(result.getString("message")).contains("a second copy was 
not started")
+                .contains("started with its own files").doesNotContain("is 
reloaded");
+    }
+
+    @Test
+    void theRunningIntegrationIsFoundThroughASymlink() throws Exception {
+        Path link = Files.createSymbolicLink(home.resolve("project-link"), 
project);
+        JsonObject result = IntegrationLauncher.run(link, List.of(), null, 
true, List.of());
+        assertThat(result.getString("status")).isEqualTo("running");
+        
assertThat(result.getLong("pid")).isEqualTo(ProcessHandle.current().pid());
+    }
+}
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/IntegrationLauncherTest.java
 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/IntegrationLauncherTest.java
index 5d5fca25d53e..b263e6d876d9 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/IntegrationLauncherTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/IntegrationLauncherTest.java
@@ -16,6 +16,7 @@
  */
 package org.apache.camel.dsl.jbang.core.commands.ai;
 
+import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.List;
@@ -61,4 +62,98 @@ class IntegrationLauncherTest {
                 .containsExactly("run", "a.camel.yaml", 
"application.properties", "--dev", "--name=demo",
                         "--logging-color=false", "--port=9000");
     }
+
+    /**
+     * CAMEL-25364: a failed start returns what went wrong, not the last stack 
frames of the runtime. The cause and the
+     * first frame of the user's code are kept, the frames are counted.
+     */
+    @Test
+    void failureOutputKeepsTheCauseNotTheFrames(@TempDir Path dir) throws 
Exception {
+        StringBuilder out = new StringBuilder();
+        out.append("2026-10-06 01:12:03.101  INFO 81990 --- [           main] 
org.apache.camel.main.MainSupport  :"
+                   + " Apache Camel (JBang) 4.23.0-SNAPSHOT is starting\n");
+        out.append("2026-10-06 01:12:04.552 ERROR 81990 --- [           main] 
org.apache.camel.main.MainSupport  :"
+                   + " Failed to create route: order-generator at: >>> 
Bean[ref:orderNumber method:next] <<<\n");
+        out.append("org.apache.camel.FailedToCreateRouteException: Failed to 
create route: order-generator\n");
+        for (int i = 0; i < 30; i++) {
+            out.append("\tat 
org.apache.camel.impl.engine.AbstractCamelContext.startingRoutes(AbstractCamelContext.java:")
+                    .append(1196 + i).append(")\n");
+        }
+        out.append("Caused by: org.apache.camel.NoSuchBeanException: No bean 
could be found in the registry for:"
+                   + " orderNumber\n");
+        out.append("\tat 
org.apache.camel.component.bean.RegistryBean.getBean(RegistryBean.java:94)\n");
+        out.append("\tat 
camel.example.OrderNumber.next(OrderNumber.java:12)\n");
+        out.append("\t... 30 more\n");
+        Path output = dir.resolve("camel-launch.log");
+        Files.writeString(output, out.toString());
+
+        String text = IntegrationLauncher.failureOutput(output);
+        assertThat(text).contains("ERROR Failed to create route: 
order-generator at: >>> Bean[ref:orderNumber");
+        
assertThat(text).contains("org.apache.camel.FailedToCreateRouteException: 
Failed to create route: order-generator");
+        assertThat(text).contains("Caused by: 
org.apache.camel.NoSuchBeanException: No bean could be found in the"
+                                  + " registry for: orderNumber");
+        assertThat(text).contains("at: 
camel.example.OrderNumber.next(OrderNumber.java:12)");
+        assertThat(text).contains("(stack frames left out)");
+        assertThat(text).doesNotContain("AbstractCamelContext.startingRoutes");
+        // oldest first, as the console printed it
+        assertThat(text.indexOf("is 
starting")).isLessThan(text.indexOf("Failed to create route"));
+    }
+
+    /**
+     * CAMEL-25364: an exception message over several lines is kept, such as 
the diagnostics of a Java class that does
+     * not compile: they are continuation lines in the same block as the 
frames, and this output is the only copy.
+     */
+    @Test
+    void failureOutputKeepsAMessageOverSeveralLines(@TempDir Path dir) throws 
Exception {
+        String out = "2026-10-06 01:12:04.552 ERROR 81990 --- [           
main] org.apache.camel.main.KameletMain  :"
+                     + " Error starting Camel: org.joor.ReflectException: 
Compilation error:\n"
+                     + "org.joor.ReflectException: Compilation error:\n"
+                     + "/work/OrderNumber.java:12: error: cannot find symbol\n"
+                     + "        return prefix + counter.incrementAndGet();\n"
+                     + "  symbol:   variable counter\n"
+                     + "\tat org.joor.Compile.compile(Compile.java:178)\n"
+                     + "\tat 
org.apache.camel.dsl.java.joor.MultiCompile.compileUnit(MultiCompile.java:207)\n"
+                     + "\tat 
org.apache.camel.main.KameletMain.run(KameletMain.java:512)\n";
+        Path output = dir.resolve("camel-launch.log");
+        Files.writeString(output, out);
+
+        String text = IntegrationLauncher.failureOutput(output);
+        assertThat(text).contains("ERROR Error starting Camel: 
org.joor.ReflectException: Compilation error:");
+        assertThat(text).contains("/work/OrderNumber.java:12: error: cannot 
find symbol");
+        assertThat(text).contains("symbol:   variable counter");
+        assertThat(text).contains("(stack frames left out)");
+        assertThat(text).doesNotContain("MultiCompile.compileUnit(");
+    }
+
+    /**
+     * CAMEL-25364: output with no log records is returned as it is, with its 
end, where a launcher prints its error.
+     */
+    @Test
+    void failureOutputWithoutLogRecordsKeepsTheEnd(@TempDir Path dir) throws 
Exception {
+        StringBuilder out = new StringBuilder();
+        for (int i = 1; i <= 60; i++) {
+            out.append("Downloading dependency ").append(i).append('\n');
+        }
+        out.append("Cannot find dependency org.example:missing:1.0\n");
+        Path output = dir.resolve("camel-launch.log");
+        Files.writeString(output, out.toString());
+        
assertThat(IntegrationLauncher.failureOutput(output)).contains("Downloading 
dependency 60")
+                .endsWith("Cannot find dependency org.example:missing:1.0");
+    }
+
+    /** CAMEL-25364: console output that is not UTF-8 (a Windows code page) is 
still returned. */
+    @Test
+    void failureOutputThatIsNotUtf8IsKept(@TempDir Path dir) throws Exception {
+        Path output = dir.resolve("camel-launch.log");
+        // C:\Users\Jörg in ISO-8859-1: the ö is a single byte that is not 
valid UTF-8
+        Files.write(output, "Cannot read 
C:\\Users\\J\u00f6rg\\route.camel.yaml\n".getBytes(StandardCharsets.ISO_8859_1));
+        assertThat(IntegrationLauncher.failureOutput(output)).contains("Cannot 
read C:\\Users\\J").contains("rg\\route");
+    }
+
+    @Test
+    void failureOutputOfPlainTextIsKept(@TempDir Path dir) throws Exception {
+        Path output = dir.resolve("camel-launch.log");
+        Files.writeString(output, "Cannot find dependency 
org.example:missing:1.0\n");
+        
assertThat(IntegrationLauncher.failureOutput(output)).isEqualTo("Cannot find 
dependency org.example:missing:1.0");
+    }
 }

Reply via email to