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 8cf0a6f155cc CAMEL-26911: camel-cli-connector - answers of actions are 
written aside and moved in place, so a tool never reads half of one (#29830)
8cf0a6f155cc is described below

commit 8cf0a6f155cc031ac410103b6d9e4fc04928ebdc
Author: Claus Ibsen <[email protected]>
AuthorDate: Fri Nov 13 16:40:19 2026 +0100

    CAMEL-26911: camel-cli-connector - answers of actions are written aside and 
moved in place, so a tool never reads half of one (#29830)
    
    The connector wrote the answer of an action in place, and the CLI handed on 
any
    text that was not blank, so the AI and MCP tools could get a truncated JSON.
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    Signed-off-by: Claus Ibsen <[email protected]>
---
 .../cli/connector/FileCliConnectorTransport.java   | 31 ++++++++++++++++++----
 .../connector/FileCliConnectorTransportTest.java   |  2 ++
 .../camel/dsl/jbang/core/common/ActionClient.java  | 21 ++++++++++++++-
 .../dsl/jbang/core/common/ActionClientTest.java    | 25 +++++++++++++++++
 4 files changed, 73 insertions(+), 6 deletions(-)

diff --git 
a/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/FileCliConnectorTransport.java
 
b/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/FileCliConnectorTransport.java
index 7aeb95c64214..5a857f9f895d 100644
--- 
a/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/FileCliConnectorTransport.java
+++ 
b/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/FileCliConnectorTransport.java
@@ -18,6 +18,8 @@ package org.apache.camel.cli.connector;
 
 import java.io.File;
 import java.io.FileInputStream;
+import java.io.IOException;
+import java.nio.file.AtomicMoveNotSupportedException;
 import java.nio.file.Files;
 import java.nio.file.StandardCopyOption;
 import java.util.HashMap;
@@ -341,13 +343,28 @@ public class FileCliConnectorTransport extends 
ServiceSupport implements CliConn
         // a deleted request means the tool has given up on it
         if (request.exists()) {
             try {
-                IOHelper.writeText(reply.toJson(), output);
+                // written aside and moved in place: the tool reads the answer 
as soon as it is there (CAMEL-26911)
+                writeAtomically(reply.toJson(), output);
             } catch (Exception e) {
                 LOG.warn("Error writing action result: {} due to: {}", 
request, e.getMessage(), e);
             }
         }
     }
 
+    /**
+     * Writes the text to a file aside and moves it in place, so a reader 
never finds half of it, as the status file.
+     */
+    private static void writeAtomically(String text, File file) throws 
IOException {
+        File temporary = new File(file.getPath() + ".tmp");
+        IOHelper.writeText(text, temporary);
+        try {
+            Files.move(temporary.toPath(), file.toPath(), 
StandardCopyOption.ATOMIC_MOVE,
+                    StandardCopyOption.REPLACE_EXISTING);
+        } catch (AtomicMoveNotSupportedException e) {
+            Files.move(temporary.toPath(), file.toPath(), 
StandardCopyOption.REPLACE_EXISTING);
+        }
+    }
+
     private static String readAction(File file) {
         try {
             if (file != null && file.exists()) {
@@ -410,7 +427,7 @@ public class FileCliConnectorTransport extends 
ServiceSupport implements CliConn
                 // store debugs in a special file
                 LOG.trace("Updating debug file: {}", debugFile);
                 String data = json.toJson() + System.lineSeparator();
-                IOHelper.writeText(data, debugFile);
+                writeAtomically(data, debugFile);
             }
         } catch (Exception e) {
             // ignore
@@ -423,7 +440,7 @@ public class FileCliConnectorTransport extends 
ServiceSupport implements CliConn
                 // store replays in a special file
                 LOG.trace("Updating message-history file: {}", 
messageHistoryFile);
                 String data = json.toJson() + System.lineSeparator();
-                IOHelper.writeText(data, messageHistoryFile);
+                writeAtomically(data, messageHistoryFile);
             }
         } catch (Exception e) {
             // ignore
@@ -435,7 +452,7 @@ public class FileCliConnectorTransport extends 
ServiceSupport implements CliConn
             if (json != null && !json.isEmpty()) {
                 LOG.trace("Updating error file: {}", errorFile);
                 String data = json.toJson() + System.lineSeparator();
-                IOHelper.writeText(data, errorFile);
+                writeAtomically(data, errorFile);
             }
         } catch (Exception e) {
             // ignore
@@ -472,7 +489,7 @@ public class FileCliConnectorTransport extends 
ServiceSupport implements CliConn
             if (json != null) {
                 LOG.trace("Updating timeline file: {}", timelineFile);
                 String data = json.toJson() + System.lineSeparator();
-                IOHelper.writeText(data, timelineFile);
+                writeAtomically(data, timelineFile);
             }
         } catch (Exception e) {
             // ignore
@@ -518,18 +535,22 @@ public class FileCliConnectorTransport extends 
ServiceSupport implements CliConn
         }
         if (messageHistoryFile != null) {
             FileUtil.deleteFile(messageHistoryFile);
+            FileUtil.deleteFile(new File(messageHistoryFile.getPath() + 
".tmp"));
         }
         if (errorFile != null) {
             FileUtil.deleteFile(errorFile);
+            FileUtil.deleteFile(new File(errorFile.getPath() + ".tmp"));
         }
         if (debugFile != null) {
             FileUtil.deleteFile(debugFile);
+            FileUtil.deleteFile(new File(debugFile.getPath() + ".tmp"));
         }
         if (receiveFile != null) {
             FileUtil.deleteFile(receiveFile);
         }
         if (timelineFile != null) {
             FileUtil.deleteFile(timelineFile);
+            FileUtil.deleteFile(new File(timelineFile.getPath() + ".tmp"));
         }
         if (executor != null) {
             camelContext.getExecutorServiceManager().shutdown(executor);
diff --git 
a/dsl/camel-cli-connector/src/test/java/org/apache/camel/cli/connector/FileCliConnectorTransportTest.java
 
b/dsl/camel-cli-connector/src/test/java/org/apache/camel/cli/connector/FileCliConnectorTransportTest.java
index 463fe793fdd5..e7cd97f41fb1 100644
--- 
a/dsl/camel-cli-connector/src/test/java/org/apache/camel/cli/connector/FileCliConnectorTransportTest.java
+++ 
b/dsl/camel-cli-connector/src/test/java/org/apache/camel/cli/connector/FileCliConnectorTransportTest.java
@@ -128,6 +128,8 @@ class FileCliConnectorTransportTest {
         writeAction(action("result"), "r1");
 
         assertThat(awaitJson(file(PID + 
"-output-r1.json"))).containsEntry("status", "success").containsEntry("value", 
"42");
+        // written aside and moved in place, so the tool never reads half a 
reply (CAMEL-26911)
+        assertThat(file(PID + "-output-r1.json.tmp")).doesNotExist();
     }
 
     @Test
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/ActionClient.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/ActionClient.java
index 090900d1f096..ed751bc19efb 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/ActionClient.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/ActionClient.java
@@ -21,6 +21,7 @@ import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.StandardCopyOption;
 import java.util.UUID;
+import java.util.concurrent.atomic.AtomicReference;
 import java.util.function.Consumer;
 import java.util.function.Function;
 import java.util.regex.Matcher;
@@ -204,7 +205,25 @@ public final class ActionClient {
      * @throws IOException when the action cannot be written
      */
     public static String executeText(long pid, JsonObject request, long 
timeout) throws IOException {
-        return exchange(pid, versionOf(pid), request, timeout, true, text -> 
text).reply();
+        // the answer once it is all there, as an integration of an earlier 
Camel version writes it in place
+        // (CAMEL-26911): JSON once it parses, other text once it reads the 
same at two polls
+        AtomicReference<String> previous = new AtomicReference<>();
+        return exchange(pid, versionOf(pid), request, timeout, true, text -> {
+            if (isJson(text)) {
+                return text;
+            }
+            boolean json = text.stripLeading().startsWith("{") || 
text.stripLeading().startsWith("[");
+            return !json && text.equals(previous.getAndSet(text)) ? text : 
null;
+        }).reply();
+    }
+
+    private static boolean isJson(String text) {
+        try {
+            Jsoner.deserialize(text);
+            return true;
+        } catch (Exception e) {
+            return false;
+        }
     }
 
     // the answer to an action, as read, or null; and whether the integration 
took the action
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/ActionClientTest.java
 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/ActionClientTest.java
index 80740006e994..2a3d7388f2bd 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/ActionClientTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/ActionClientTest.java
@@ -169,6 +169,31 @@ class ActionClientTest {
         assertThat(result.reply().getInteger("value")).isEqualTo(42);
     }
 
+    @Test
+    void textOfAnAnswerIsReadWhenItIsAllThere() throws Exception {
+        Files.writeString(CommandLineHelper.getCamelDir().resolve(PID + 
"-status.json"),
+                "{\"context\":{\"version\":\"4.23.0\"}}");
+        String answer = 
"{\"status\":\"success\",\"routes\":[{\"id\":\"route1\"}]}";
+        // an integration of an earlier Camel version writes the answer in 
place: half of it is there first
+        executor.submit(() -> {
+            Path dir = CommandLineHelper.getCamelDir();
+            Path request = await().atMost(5, TimeUnit.SECONDS).until(() -> 
requestFiles(dir), files -> !files.isEmpty())
+                    .get(0);
+            String id = request.getFileName().toString().substring((PID + 
"-action-").length()).replace(".json", "");
+            Path output = dir.resolve(PID + "-output-" + id + ".json");
+            Files.writeString(output, answer.substring(0, 20));
+            // several polls of the client find half the answer
+            await().pollDelay(500, TimeUnit.MILLISECONDS).atMost(1, 
TimeUnit.SECONDS).until(() -> true);
+            Files.writeString(output, answer);
+            Files.delete(request);
+            return null;
+        });
+
+        JsonObject request = new JsonObject();
+        request.put("action", "route-dump");
+        assertThat(ActionClient.executeText(PID, request, 
5000)).isEqualTo(answer);
+    }
+
     @Test
     void errorMessageComesFromTheAnswer() {
         
assertThat(ActionClient.result(json("{\"status\":\"error\",\"exception\":{\"message\":\"Forced\"}}")).error())

Reply via email to