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())