This is an automated email from the ASF dual-hosted git repository.
lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git
The following commit(s) were added to refs/heads/rocketmq-studio by this push:
new a10faa9a [ISSUE #1517] Enforce timeout in Claude CLI streaming (#1518)
a10faa9a is described below
commit a10faa9a7b5bcbff57e56fe2b6507f5aa671765b
Author: youngkermit8-coder <[email protected]>
AuthorDate: Tue Aug 11 20:20:51 2026 +0800
[ISSUE #1517] Enforce timeout in Claude CLI streaming (#1518)
Signed-off-by: youngkermit8-coder <[email protected]>
---
.../studio/ops/ai/ClaudeCodeAgentProvider.java | 67 +++++++++++++---
.../studio/ops/ai/ClaudeCodeAgentProviderTest.java | 90 ++++++++++++++++++++++
2 files changed, 147 insertions(+), 10 deletions(-)
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/ClaudeCodeAgentProvider.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/ClaudeCodeAgentProvider.java
index ea7e8a52..8e83df37 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/ClaudeCodeAgentProvider.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/ClaudeCodeAgentProvider.java
@@ -24,13 +24,17 @@ import org.springframework.util.StringUtils;
import java.io.BufferedReader;
import java.io.IOException;
+import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;
@@ -47,6 +51,7 @@ public class ClaudeCodeAgentProvider extends CliAgentProvider
{
private static final String COMPATIBLE_MODE_SUFFIX = "/compatible-mode/v1";
private static final String ANTHROPIC_APP_SUFFIX = "/apps/anthropic";
private static final long STREAM_TIMEOUT_SECONDS = 300;
+ private static final long OUTPUT_DRAIN_TIMEOUT_SECONDS = 10;
private final LlmProperties llmProperties;
private final ObjectMapper objectMapper = new ObjectMapper();
@@ -96,22 +101,20 @@ public class ClaudeCodeAgentProvider extends
CliAgentProvider {
Process process = builder.start();
AtomicBoolean emitted = new AtomicBoolean(false);
StringBuilder resultText = new StringBuilder();
- try (BufferedReader reader = new BufferedReader(
- new InputStreamReader(process.getInputStream(),
StandardCharsets.UTF_8))) {
- String line;
- while ((line = reader.readLine()) != null) {
- parseStreamLine(line, tokenConsumer, emitted, resultText);
- }
- }
- boolean finished = process.waitFor(STREAM_TIMEOUT_SECONDS,
TimeUnit.SECONDS);
+ CompletableFuture<Void> stdoutFuture = drainStdout(
+ process.getInputStream(), tokenConsumer, emitted,
resultText);
+ CompletableFuture<String> stderrFuture =
readAsync(process.getErrorStream());
+ long timeoutSeconds = streamTimeoutSeconds();
+ boolean finished = process.waitFor(timeoutSeconds,
TimeUnit.SECONDS);
if (!finished) {
process.destroyForcibly();
throw new LlmGatewayException(504, "llm.provider.timeout",
- binaryName() + " CLI stream timed out after " +
STREAM_TIMEOUT_SECONDS + "s",
+ binaryName() + " CLI stream timed out after " +
timeoutSeconds + "s",
"Retry with a shorter prompt or check the gateway
latency.");
}
+ await(stdoutFuture);
+ String stderr = await(stderrFuture);
if (process.exitValue() != 0 && !emitted.get()) {
- String stderr = new
String(process.getErrorStream().readAllBytes(), StandardCharsets.UTF_8);
throw new LlmGatewayException(502, "llm.provider.cli_error",
binaryName() + " CLI failed: " +
(StringUtils.hasText(stderr) ? stderr.trim() : "unknown error"),
"Check the provider credentials, base URL and model
name.");
@@ -130,6 +133,50 @@ public class ClaudeCodeAgentProvider extends
CliAgentProvider {
}
}
+ protected long streamTimeoutSeconds() {
+ return STREAM_TIMEOUT_SECONDS;
+ }
+
+ private CompletableFuture<Void> drainStdout(InputStream stdout,
Consumer<String> tokenConsumer,
+ AtomicBoolean emitted,
StringBuilder resultText) {
+ CompletableFuture<Void> result = new CompletableFuture<>();
+ Thread.ofVirtual().start(() -> {
+ try (BufferedReader reader = new BufferedReader(
+ new InputStreamReader(stdout, StandardCharsets.UTF_8))) {
+ String line;
+ while ((line = reader.readLine()) != null) {
+ parseStreamLine(line, tokenConsumer, emitted, resultText);
+ }
+ result.complete(null);
+ } catch (Exception exception) {
+ result.completeExceptionally(exception);
+ }
+ });
+ return result;
+ }
+
+ private CompletableFuture<String> readAsync(InputStream stream) {
+ CompletableFuture<String> result = new CompletableFuture<>();
+ Thread.ofVirtual().start(() -> {
+ try (stream) {
+ result.complete(new String(stream.readAllBytes(),
StandardCharsets.UTF_8));
+ } catch (Exception exception) {
+ result.completeExceptionally(exception);
+ }
+ });
+ return result;
+ }
+
+ private <T> T await(CompletableFuture<T> future) throws IOException,
InterruptedException {
+ try {
+ return future.get(OUTPUT_DRAIN_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+ } catch (ExecutionException exception) {
+ throw new IOException("Failed to drain Claude CLI output",
exception.getCause());
+ } catch (TimeoutException exception) {
+ throw new IOException("Timed out while draining Claude CLI
output", exception);
+ }
+ }
+
/** Parses one stream-json line: emits text deltas, records the final
result. */
private void parseStreamLine(String line, Consumer<String> tokenConsumer,
AtomicBoolean emitted, StringBuilder
resultText) {
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/ClaudeCodeAgentProviderTest.java
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/ClaudeCodeAgentProviderTest.java
new file mode 100644
index 00000000..8cedc2dd
--- /dev/null
+++
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/ClaudeCodeAgentProviderTest.java
@@ -0,0 +1,90 @@
+/*
+ * 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.rocketmq.studio.ops.ai;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class ClaudeCodeAgentProviderTest {
+
+ @Test
+ void streamShouldDrainLargeStderrOutputTest() {
+ TestClaudeCodeAgentProvider provider = new
TestClaudeCodeAgentProvider(List.of(
+ "sh", "-c", "yes error | head -c 131072 >&2; "
+ + "printf eyJ0eXBlIjoicmVzdWx0IiwicmVzdWx0IjoiZG9uZSJ9
| base64 -d"), 5);
+ List<String> tokens = new ArrayList<>();
+
+ provider.stream(LlmConfigVO.builder().build(), "prompt", null,
tokens::add);
+
+ assertThat(tokens).containsExactly("done");
+ }
+
+ @Test
+ void streamShouldEnforceTimeoutBeforeWaitingForStdoutTest() {
+ TestClaudeCodeAgentProvider provider = new TestClaudeCodeAgentProvider(
+ List.of("sh", "-c", "sleep 2"), 1);
+
+ assertThatThrownBy(() -> provider.stream(
+ LlmConfigVO.builder().build(), "prompt", null, ignored -> { }))
+ .isInstanceOf(LlmGatewayException.class)
+ .satisfies(exception -> assertThat(((LlmGatewayException)
exception).getStatusCode())
+ .isEqualTo(504));
+ }
+
+ private static class TestClaudeCodeAgentProvider extends
ClaudeCodeAgentProvider {
+
+ private final List<String> command;
+ private final long timeoutSeconds;
+
+ TestClaudeCodeAgentProvider(List<String> command, long timeoutSeconds)
{
+ super(null);
+ this.command = command;
+ this.timeoutSeconds = timeoutSeconds;
+ }
+
+ @Override
+ public boolean available() {
+ return true;
+ }
+
+ @Override
+ protected List<String> buildCommand(LlmConfigVO config, String prompt,
String modelOverride) {
+ return new ArrayList<>(command);
+ }
+
+ @Override
+ protected Map<String, String> childEnv(LlmConfigVO config) {
+ return Map.of();
+ }
+
+ @Override
+ protected String binaryName() {
+ return "sh";
+ }
+
+ @Override
+ protected long streamTimeoutSeconds() {
+ return timeoutSeconds;
+ }
+ }
+}