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 3bc0f7473 fix(ai): cancel abandoned streams and reject overload (#2209)
3bc0f7473 is described below

commit 3bc0f74739ad11de005ff03991ec2d40c59c850e
Author: xdz997 <[email protected]>
AuthorDate: Wed Aug 19 14:24:09 2026 +0800

    fix(ai): cancel abandoned streams and reject overload (#2209)
---
 .../studio/ops/ai/ClaudeCodeAgentProvider.java     |  10 +-
 .../rocketmq/studio/ops/ai/LlmSseSession.java      | 129 ++++++++++++
 .../studio/ops/ai/OpenAiCompatibleLlmGateway.java  | 171 ++++++++++++----
 .../studio/ops/ai/ClaudeCodeAgentProviderTest.java |  70 ++++++-
 .../rocketmq/studio/ops/ai/LlmSseSessionTest.java  | 130 ++++++++++++
 .../ops/ai/OpenAiCompatibleLlmGatewayTest.java     | 227 +++++++++++++++++++++
 6 files changed, 691 insertions(+), 46 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 033eb6ff3..8646ca74d 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
@@ -98,8 +98,9 @@ public class ClaudeCodeAgentProvider extends CliAgentProvider 
{
         ProcessBuilder builder = new ProcessBuilder(command);
         processEnvironment().apply(builder, childEnv(config));
         builder.redirectErrorStream(false);
+        Process process = null;
         try {
-            Process process = builder.start();
+            process = startProcess(builder);
             AtomicBoolean emitted = new AtomicBoolean(false);
             StringBuilder resultText = new StringBuilder();
             CompletableFuture<Void> stdoutFuture = drainStdout(
@@ -128,12 +129,19 @@ public class ClaudeCodeAgentProvider extends 
CliAgentProvider {
                     "Failed to execute " + binaryName() + " CLI",
                     "Check that the CLI binary is installed and executable.", 
exception);
         } catch (InterruptedException exception) {
+            if (process != null) {
+                process.destroyForcibly();
+            }
             Thread.currentThread().interrupt();
             throw new LlmGatewayException(502, "llm.provider.interrupted",
                     binaryName() + " CLI execution was interrupted", "Retry 
the request.", exception);
         }
     }
 
+    protected Process startProcess(ProcessBuilder builder) throws IOException {
+        return builder.start();
+    }
+
     protected long streamTimeoutSeconds() {
         return STREAM_TIMEOUT_SECONDS;
     }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/LlmSseSession.java 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/LlmSseSession.java
new file mode 100644
index 000000000..0835c4024
--- /dev/null
+++ b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/LlmSseSession.java
@@ -0,0 +1,129 @@
+/*
+ * 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.springframework.web.servlet.mvc.method.annotation.SseEmitter;
+
+import java.io.IOException;
+import java.util.Objects;
+import java.util.concurrent.Future;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Consumer;
+
+/**
+ * Couples one downstream SSE response with its asynchronous provider task.
+ * Client completion, timeout, and transport errors cancel the task so an
+ * abandoned response does not retain a gateway worker until provider timeout.
+ */
+final class LlmSseSession {
+
+    private enum State {
+        ACTIVE,
+        TERMINATING,
+        TERMINATED,
+        CANCELLED
+    }
+
+    private final SseEmitter emitter;
+    private final Consumer<LlmSseSession> terminationListener;
+    private final AtomicReference<State> state = new 
AtomicReference<>(State.ACTIVE);
+    private final AtomicReference<Future<?>> task = new AtomicReference<>();
+    private final AtomicBoolean terminationNotified = new AtomicBoolean();
+
+    LlmSseSession(SseEmitter emitter, Consumer<LlmSseSession> 
terminationListener) {
+        this.emitter = Objects.requireNonNull(emitter, "emitter");
+        this.terminationListener = Objects.requireNonNull(terminationListener, 
"terminationListener");
+        emitter.onCompletion(this::cancel);
+        emitter.onTimeout(this::cancel);
+        emitter.onError(ignored -> cancel());
+    }
+
+    SseEmitter emitter() {
+        return emitter;
+    }
+
+    void attach(Future<?> submittedTask) {
+        Objects.requireNonNull(submittedTask, "submittedTask");
+        if (!task.compareAndSet(null, submittedTask)) {
+            submittedTask.cancel(true);
+            throw new IllegalStateException("An SSE session can own only one 
task");
+        }
+        if (state.get() == State.CANCELLED) {
+            submittedTask.cancel(true);
+        }
+    }
+
+    boolean beginTerminal() {
+        return state.compareAndSet(State.ACTIVE, State.TERMINATING);
+    }
+
+    void send(SseEmitter.SseEventBuilder event) throws IOException {
+        State current = state.get();
+        if (current == State.CANCELLED || current == State.TERMINATED) {
+            throw new IOException("SSE client is no longer connected");
+        }
+        try {
+            emitter.send(event);
+        } catch (IOException exception) {
+            cancel();
+            throw exception;
+        }
+    }
+
+    void complete() {
+        if (state.compareAndSet(State.TERMINATING, State.TERMINATED)) {
+            notifyTermination();
+            emitter.complete();
+        }
+    }
+
+    void completeWithError(Throwable throwable) {
+        State previous = state.getAndUpdate(current -> switch (current) {
+            case ACTIVE, TERMINATING -> State.TERMINATED;
+            case TERMINATED, CANCELLED -> current;
+        });
+        if (previous == State.ACTIVE || previous == State.TERMINATING) {
+            notifyTermination();
+            emitter.completeWithError(throwable);
+        }
+    }
+
+    void cancel() {
+        State previous = state.getAndUpdate(current -> switch (current) {
+            case ACTIVE, TERMINATING -> State.CANCELLED;
+            case TERMINATED, CANCELLED -> current;
+        });
+        if (previous == State.ACTIVE || previous == State.TERMINATING) {
+            Future<?> submittedTask = task.get();
+            if (submittedTask != null) {
+                submittedTask.cancel(true);
+            }
+            notifyTermination();
+        }
+    }
+
+    boolean isCancelled() {
+        return state.get() == State.CANCELLED;
+    }
+
+    private void notifyTermination() {
+        if (terminationNotified.compareAndSet(false, true)) {
+            terminationListener.accept(this);
+        }
+    }
+}
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/OpenAiCompatibleLlmGateway.java
 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/OpenAiCompatibleLlmGateway.java
index 8ec0f825d..efdb6d1d4 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/OpenAiCompatibleLlmGateway.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/OpenAiCompatibleLlmGateway.java
@@ -19,8 +19,8 @@ package org.apache.rocketmq.studio.ops.ai;
 import com.fasterxml.jackson.core.JsonProcessingException;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import jakarta.annotation.PreDestroy;
-import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.context.annotation.Primary;
 import org.springframework.stereotype.Component;
 import org.springframework.util.StringUtils;
@@ -31,27 +31,62 @@ import java.io.InputStream;
 import java.nio.charset.StandardCharsets;
 import java.util.Locale;
 import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
+import java.util.concurrent.RejectedExecutionException;
 import java.util.concurrent.SynchronousQueue;
 import java.util.concurrent.ThreadPoolExecutor;
 import java.util.concurrent.TimeUnit;
+import java.util.function.Consumer;
+import java.util.function.LongFunction;
 
 @Slf4j
 @Primary
 @Component
-@RequiredArgsConstructor
 public class OpenAiCompatibleLlmGateway implements LlmGateway {
 
+    private static final long HTTP_STREAM_TIMEOUT_MILLIS = 60_000L;
+    private static final long CLI_STREAM_TIMEOUT_MILLIS = 300_000L;
+    private static final int MAX_CONCURRENT_CHATS = 16;
+
     private final LlmConfigService configService;
     private final OpenAiCompatibleLlmClient llmClient;
     private final AgentProviderRegistry agentProviders;
     private final ObjectMapper objectMapper;
-    // Bounded pool: cached threads grow without limit under load and, 
combined with a hung CLI
-    // child, can exhaust memory. CallerRunsPolicy keeps SSE work from being 
dropped under load.
-    private final ExecutorService executor = new ThreadPoolExecutor(
-            0, 16, 60L, TimeUnit.SECONDS,
-            new SynchronousQueue<>(),
-            new ThreadPoolExecutor.CallerRunsPolicy());
+    private final ExecutorService executor;
+    private final LongFunction<SseEmitter> emitterFactory;
+    private final Set<LlmSseSession> activeSessions = 
ConcurrentHashMap.newKeySet();
+
+    @Autowired
+    public OpenAiCompatibleLlmGateway(LlmConfigService configService,
+                                      OpenAiCompatibleLlmClient llmClient,
+                                      AgentProviderRegistry agentProviders,
+                                      ObjectMapper objectMapper) {
+        this(configService, llmClient, agentProviders, objectMapper, 
newChatExecutor(), SseEmitter::new);
+    }
+
+    OpenAiCompatibleLlmGateway(LlmConfigService configService,
+                               OpenAiCompatibleLlmClient llmClient,
+                               AgentProviderRegistry agentProviders,
+                               ObjectMapper objectMapper,
+                               ExecutorService executor,
+                               LongFunction<SseEmitter> emitterFactory) {
+        this.configService = configService;
+        this.llmClient = llmClient;
+        this.agentProviders = agentProviders;
+        this.objectMapper = objectMapper;
+        this.executor = executor;
+        this.emitterFactory = emitterFactory;
+    }
+
+    private static ExecutorService newChatExecutor() {
+        return new ThreadPoolExecutor(
+                0, MAX_CONCURRENT_CHATS, 60L, TimeUnit.SECONDS,
+                new SynchronousQueue<>(),
+                new ThreadPoolExecutor.AbortPolicy());
+    }
 
     @Override
     public SseEmitter chat(ChatDTO request) {
@@ -61,17 +96,15 @@ public class OpenAiCompatibleLlmGateway implements 
LlmGateway {
         }
         String engine = resolveEngine(request == null ? null : 
request.getEngine(), config);
         if (isCliEngine(engine)) {
-            SseEmitter emitter = new SseEmitter(300_000L);
-            executor.execute(() -> runCliChat(request, config, engine, 
emitter));
-            return emitter;
+            return submitChat(CLI_STREAM_TIMEOUT_MILLIS,
+                    session -> runCliChat(request, config, engine, session));
         }
         if (!llmClient.supports(config)) {
             return errorEmitter(unsupportedProviderException());
         }
 
-        SseEmitter emitter = new SseEmitter(60_000L);
-        executor.execute(() -> streamChat(request, config, emitter));
-        return emitter;
+        return submitChat(HTTP_STREAM_TIMEOUT_MILLIS,
+                session -> streamChat(request, config, session));
     }
 
     @Override
@@ -106,23 +139,28 @@ public class OpenAiCompatibleLlmGateway implements 
LlmGateway {
         return !LlmConfigVO.ENGINE_HTTP.equalsIgnoreCase(engine);
     }
 
-    private void runCliChat(ChatDTO request, LlmConfigVO config, String 
engine, SseEmitter emitter) {
+    private void runCliChat(ChatDTO request, LlmConfigVO config, String 
engine, LlmSseSession session) {
         try {
             AgentProvider provider = agentProviders.forEngine(engine);
             String prompt = request == null ? null : request.getMessage();
             if (request != null && request.isEnhance() && 
StringUtils.hasText(prompt)) {
-                prompt = enhanceAndEmit(config, provider, prompt, emitter);
+                prompt = enhanceAndEmit(config, provider, prompt, session);
             }
             String result = provider.complete(config, prompt, request == null 
? null : request.getModel());
-            sendMessage(emitter, result);
-            emitter.send(SseEmitter.event().name("done").data("[DONE]"));
-            emitter.complete();
+            sendMessage(session, result);
+            finishSuccess(session);
         } catch (LlmGatewayException exception) {
+            if (session.isCancelled()) {
+                return;
+            }
             log.warn("Agent CLI chat failed: {}", exception.getCode(), 
exception);
-            sendError(emitter, exception);
+            sendError(session, exception);
         } catch (Exception exception) {
+            if (session.isCancelled()) {
+                return;
+            }
             log.error("Failed to run agent CLI chat", exception);
-            sendError(emitter, new LlmGatewayException(502, 
"llm.gateway_error",
+            sendError(session, new LlmGatewayException(502, 
"llm.gateway_error",
                     "Failed to run agent CLI chat", "Check the agent provider 
configuration and retry.", exception));
         }
     }
@@ -142,36 +180,36 @@ public class OpenAiCompatibleLlmGateway implements 
LlmGateway {
     }
 
     /** Streams the prompt rewrite via the given provider, emitting per-chunk 
"enhance" SSE events. */
-    private String enhanceAndEmit(LlmConfigVO config, AgentProvider provider, 
String rawPrompt, SseEmitter emitter)
+    private String enhanceAndEmit(LlmConfigVO config, AgentProvider provider, 
String rawPrompt, LlmSseSession session)
             throws IOException {
         String metaPrompt = ENHANCE_PROMPT_TEMPLATE.formatted(rawPrompt);
         StringBuilder accumulated = new StringBuilder();
         provider.stream(config, metaPrompt, null, chunk -> {
             accumulated.append(chunk);
-            emitEnhanceChunk(emitter, chunk);
+            emitEnhanceChunk(session, chunk);
         });
         String enhanced = cleanEnhancedPrompt(accumulated.toString());
         return StringUtils.hasText(enhanced) ? enhanced : rawPrompt;
     }
 
-    private String enhanceAndEmitHttp(LlmConfigVO config, String rawPrompt, 
SseEmitter emitter)
+    private String enhanceAndEmitHttp(LlmConfigVO config, String rawPrompt, 
LlmSseSession session)
             throws IOException {
         String metaPrompt = ENHANCE_PROMPT_TEMPLATE.formatted(rawPrompt);
         StringBuilder accumulated = new StringBuilder();
         llmClient.stream(config, metaPrompt, null, chunk -> {
             accumulated.append(chunk);
-            emitEnhanceChunk(emitter, chunk);
+            emitEnhanceChunk(session, chunk);
         });
         String enhanced = cleanEnhancedPrompt(accumulated.toString());
         return StringUtils.hasText(enhanced) ? enhanced : rawPrompt;
     }
 
-    private void emitEnhanceChunk(SseEmitter emitter, String chunk) {
+    private void emitEnhanceChunk(LlmSseSession session, String chunk) {
         if (!StringUtils.hasText(chunk)) {
             return;
         }
         try {
-            emitter.send(SseEmitter.event()
+            session.send(SseEmitter.event()
                     .name("enhance")
                     .data(objectMapper.writeValueAsString(Map.of("delta", 
chunk))));
         } catch (IOException exception) {
@@ -193,33 +231,39 @@ public class OpenAiCompatibleLlmGateway implements 
LlmGateway {
 
     @PreDestroy
     void destroy() {
+        activeSessions.forEach(LlmSseSession::cancel);
         executor.shutdownNow();
     }
 
-    private void streamChat(ChatDTO request, LlmConfigVO config, SseEmitter 
emitter) {
+    private void streamChat(ChatDTO request, LlmConfigVO config, LlmSseSession 
session) {
         try {
             String prompt = request == null ? null : request.getMessage();
             if (request != null && request.isEnhance() && 
StringUtils.hasText(prompt)) {
-                prompt = enhanceAndEmitHttp(config, prompt, emitter);
+                prompt = enhanceAndEmitHttp(config, prompt, session);
             }
             llmClient.stream(config, prompt,
                     request == null ? null : request.getModel(),
-                    token -> sendMessage(emitter, token));
-            emitter.send(SseEmitter.event().name("done").data("[DONE]"));
-            emitter.complete();
+                    token -> sendMessage(session, token));
+            finishSuccess(session);
         } catch (LlmGatewayException exception) {
+            if (session.isCancelled()) {
+                return;
+            }
             log.warn("LLM chat stream failed: {}", exception.getCode(), 
exception);
-            sendError(emitter, exception);
+            sendError(session, exception);
         } catch (Exception exception) {
+            if (session.isCancelled()) {
+                return;
+            }
             log.error("Failed to stream LLM chat response", exception);
-            sendError(emitter, new LlmGatewayException(502, 
"llm.gateway_error",
+            sendError(session, new LlmGatewayException(502, 
"llm.gateway_error",
                     "Failed to stream LLM chat response", "Check the LLM 
provider configuration and retry.", exception));
         }
     }
 
-    private void sendMessage(SseEmitter emitter, String token) {
+    private void sendMessage(LlmSseSession session, String token) {
         try {
-            emitter.send(SseEmitter.event()
+            session.send(SseEmitter.event()
                     .name("message")
                     .data(objectMapper.writeValueAsString(Map.of("text", 
token))));
         } catch (IOException exception) {
@@ -228,25 +272,58 @@ public class OpenAiCompatibleLlmGateway implements 
LlmGateway {
         }
     }
 
+    private SseEmitter submitChat(long timeoutMillis, Consumer<LlmSseSession> 
work) {
+        LlmSseSession session = newSession(timeoutMillis);
+        try {
+            Future<?> task = executor.submit(() -> work.accept(session));
+            session.attach(task);
+        } catch (RejectedExecutionException exception) {
+            sendError(session, overloadedException());
+        }
+        return session.emitter();
+    }
+
+    private LlmSseSession newSession(long timeoutMillis) {
+        LlmSseSession session = new LlmSseSession(
+                emitterFactory.apply(timeoutMillis), activeSessions::remove);
+        activeSessions.add(session);
+        return session;
+    }
+
+    private void finishSuccess(LlmSseSession session) {
+        if (!session.beginTerminal()) {
+            return;
+        }
+        try {
+            session.send(SseEmitter.event().name("done").data("[DONE]"));
+            session.complete();
+        } catch (IOException exception) {
+            session.completeWithError(exception);
+        }
+    }
+
     private SseEmitter errorEmitter(LlmGatewayException exception) {
-        SseEmitter emitter = new SseEmitter(60_000L);
-        executor.execute(() -> sendError(emitter, exception));
-        return emitter;
+        LlmSseSession session = newSession(HTTP_STREAM_TIMEOUT_MILLIS);
+        sendError(session, exception);
+        return session.emitter();
     }
 
-    private void sendError(SseEmitter emitter, LlmGatewayException exception) {
+    private void sendError(LlmSseSession session, LlmGatewayException 
exception) {
+        if (!session.beginTerminal()) {
+            return;
+        }
         try {
-            emitter.send(SseEmitter.event()
+            session.send(SseEmitter.event()
                     .name("error")
                     .data(objectMapper.writeValueAsString(Map.of(
                             "status", exception.getStatusCode(),
                             "code", exception.getCode(),
                             "message", exception.getMessage(),
                             "hint", exception.getHint() == null ? "" : 
exception.getHint()))));
-            emitter.send(SseEmitter.event().name("done").data("[DONE]"));
-            emitter.complete();
+            session.send(SseEmitter.event().name("done").data("[DONE]"));
+            session.complete();
         } catch (IOException ioException) {
-            emitter.completeWithError(ioException);
+            session.completeWithError(ioException);
         }
     }
 
@@ -272,6 +349,12 @@ public class OpenAiCompatibleLlmGateway implements 
LlmGateway {
                 "Use one of: openai, deepseek, tongyi, ollama.");
     }
 
+    private LlmGatewayException overloadedException() {
+        return new LlmGatewayException(503, "llm.gateway.overloaded",
+                "AI chat capacity is temporarily exhausted",
+                "Wait for an active chat to finish, then retry.");
+    }
+
     private String commandPrompt(AiCommandDTO command) {
         if (command == null) {
             return "";
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
index d9767c31c..a0bfc00cb 100644
--- 
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
@@ -18,12 +18,23 @@ package org.apache.rocketmq.studio.ops.ai;
 
 import org.junit.jupiter.api.Test;
 
+import java.io.ByteArrayInputStream;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
 
 class ClaudeCodeAgentProviderTest {
 
@@ -52,6 +63,7 @@ class ClaudeCodeAgentProviderTest {
     }
 
     @Test
+
     void streamUsesTheIsolatedEnvironment() {
         RecordingEnvironment processEnvironment = new RecordingEnvironment();
         TestClaudeCodeAgentProvider provider = new TestClaudeCodeAgentProvider(
@@ -69,6 +81,44 @@ class ClaudeCodeAgentProviderTest {
                         .doesNotContainKey("SERVER_SECRET"));
     }
 
+    @Test
+    void streamInterruptionDestroysTheChildProcess() throws Exception {
+        Process process = mock(Process.class);
+        CountDownLatch waitStarted = new CountDownLatch(1);
+        when(process.getInputStream()).thenReturn(new ByteArrayInputStream(new 
byte[0]));
+        when(process.getErrorStream()).thenReturn(new ByteArrayInputStream(new 
byte[0]));
+        doAnswer(invocation -> {
+            waitStarted.countDown();
+            new CountDownLatch(1).await();
+            return false;
+        }).when(process).waitFor(anyLong(), eq(TimeUnit.SECONDS));
+        TestClaudeCodeAgentProvider provider = new TestClaudeCodeAgentProvider(
+                List.of("claude"), 300, process);
+        AtomicReference<LlmGatewayException> failure = new AtomicReference<>();
+        AtomicBoolean interruptPreserved = new AtomicBoolean();
+        CountDownLatch finished = new CountDownLatch(1);
+        Thread worker = new Thread(() -> {
+            try {
+                provider.stream(LlmConfigVO.builder().build(), "prompt", null, 
ignored -> { });
+            } catch (LlmGatewayException exception) {
+                failure.set(exception);
+                interruptPreserved.set(Thread.currentThread().isInterrupted());
+            } finally {
+                finished.countDown();
+            }
+        });
+        worker.start();
+        assertThat(waitStarted.await(5, TimeUnit.SECONDS)).isTrue();
+
+        worker.interrupt();
+
+        assertThat(finished.await(5, TimeUnit.SECONDS)).isTrue();
+        assertThat(failure.get()).isNotNull();
+        
assertThat(failure.get().getCode()).isEqualTo("llm.provider.interrupted");
+        assertThat(interruptPreserved).isTrue();
+        verify(process).destroyForcibly();
+    }
+
     private static final class RecordingEnvironment extends 
CliProcessEnvironment {
         private final List<Map<String, String>> childEnvironments = new 
ArrayList<>();
 
@@ -84,23 +134,36 @@ class ClaudeCodeAgentProviderTest {
         }
     }
 
+
     private static class TestClaudeCodeAgentProvider extends 
ClaudeCodeAgentProvider {
 
         private final List<String> command;
         private final long timeoutSeconds;
         private final Map<String, String> environment;
+        private final Process process;
 
         TestClaudeCodeAgentProvider(List<String> command, long timeoutSeconds) 
{
-            this(command, timeoutSeconds, new 
CliProcessEnvironment(List.of()), Map.of());
+            this(command, timeoutSeconds, new 
CliProcessEnvironment(List.of()), Map.of(), null);
         }
 
         TestClaudeCodeAgentProvider(List<String> command, long timeoutSeconds,
                                     CliProcessEnvironment processEnvironment,
                                     Map<String, String> environment) {
+            this(command, timeoutSeconds, processEnvironment, environment, 
null);
+        }
+
+        TestClaudeCodeAgentProvider(List<String> command, long timeoutSeconds, 
Process process) {
+            this(command, timeoutSeconds, new 
CliProcessEnvironment(List.of()), Map.of(), process);
+        }
+
+        TestClaudeCodeAgentProvider(List<String> command, long timeoutSeconds,
+                                    CliProcessEnvironment processEnvironment,
+                                    Map<String, String> environment, Process 
process) {
             super(null, processEnvironment);
             this.command = command;
             this.timeoutSeconds = timeoutSeconds;
             this.environment = environment;
+            this.process = process;
         }
 
         @Override
@@ -127,5 +190,10 @@ class ClaudeCodeAgentProviderTest {
         protected long streamTimeoutSeconds() {
             return timeoutSeconds;
         }
+
+        @Override
+        protected Process startProcess(ProcessBuilder builder) throws 
java.io.IOException {
+            return process == null ? super.startProcess(builder) : process;
+        }
     }
 }
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/LlmSseSessionTest.java 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/LlmSseSessionTest.java
new file mode 100644
index 000000000..625d24b6b
--- /dev/null
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/LlmSseSessionTest.java
@@ -0,0 +1,130 @@
+/*
+ * 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 org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
+
+import java.util.concurrent.Future;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Consumer;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+
+class LlmSseSessionTest {
+
+    @Test
+    void completionTimeoutAndErrorCancelAttachedTasks() {
+        assertLifecycleCallbackCancels(TestEmitter::triggerCompletion);
+        assertLifecycleCallbackCancels(TestEmitter::triggerTimeout);
+        assertLifecycleCallbackCancels(emitter -> emitter.triggerError(new 
IllegalStateException("closed")));
+    }
+
+    @Test
+    void terminationBeforeAttachmentCancelsTheLateTask() {
+        TestEmitter emitter = new TestEmitter();
+        LlmSseSession session = new LlmSseSession(emitter, ignored -> { });
+        Future<?> task = mock(Future.class);
+
+        emitter.triggerTimeout();
+        session.attach(task);
+
+        assertThat(session.isCancelled()).isTrue();
+        verify(task).cancel(true);
+    }
+
+    @Test
+    void normalTerminalCompletionDoesNotCancelTheTask() {
+        TestEmitter emitter = new TestEmitter();
+        AtomicInteger terminations = new AtomicInteger();
+        LlmSseSession session = new LlmSseSession(emitter, ignored -> 
terminations.incrementAndGet());
+        Future<?> task = mock(Future.class);
+        session.attach(task);
+
+        assertThat(session.beginTerminal()).isTrue();
+        session.complete();
+
+        assertThat(emitter.completed).isTrue();
+        assertThat(terminations).hasValue(1);
+        assertThat(session.isCancelled()).isFalse();
+        verify(task, never()).cancel(true);
+    }
+
+    private void assertLifecycleCallbackCancels(Consumer<TestEmitter> 
callback) {
+        TestEmitter emitter = new TestEmitter();
+        AtomicInteger terminations = new AtomicInteger();
+        LlmSseSession session = new LlmSseSession(emitter, ignored -> 
terminations.incrementAndGet());
+        Future<?> task = mock(Future.class);
+        session.attach(task);
+
+        callback.accept(emitter);
+        callback.accept(emitter);
+
+        assertThat(session.isCancelled()).isTrue();
+        assertThat(terminations).hasValue(1);
+        verify(task).cancel(true);
+    }
+
+    private static final class TestEmitter extends SseEmitter {
+        private Runnable completionCallback;
+        private Runnable timeoutCallback;
+        private Consumer<Throwable> errorCallback;
+        private boolean completed;
+
+        @Override
+        public synchronized void onCompletion(Runnable callback) {
+            this.completionCallback = callback;
+        }
+
+        @Override
+        public synchronized void onTimeout(Runnable callback) {
+            this.timeoutCallback = callback;
+        }
+
+        @Override
+        public synchronized void onError(Consumer<Throwable> callback) {
+            this.errorCallback = callback;
+        }
+
+        @Override
+        public synchronized void complete() {
+            completed = true;
+            triggerCompletion();
+        }
+
+        void triggerCompletion() {
+            if (completionCallback != null) {
+                completionCallback.run();
+            }
+        }
+
+        void triggerTimeout() {
+            if (timeoutCallback != null) {
+                timeoutCallback.run();
+            }
+        }
+
+        void triggerError(Throwable throwable) {
+            if (errorCallback != null) {
+                errorCallback.accept(throwable);
+            }
+        }
+    }
+}
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/OpenAiCompatibleLlmGatewayTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/OpenAiCompatibleLlmGatewayTest.java
index afff18ff1..23074ab12 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/OpenAiCompatibleLlmGatewayTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/OpenAiCompatibleLlmGatewayTest.java
@@ -19,14 +19,28 @@ package org.apache.rocketmq.studio.ops.ai;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import org.junit.jupiter.api.Test;
 import org.mockito.ArgumentCaptor;
+import 
org.springframework.web.servlet.mvc.method.annotation.ResponseBodyEmitter;
 import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
 
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
 import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.SynchronousQueue;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Consumer;
 
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doAnswer;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.verify;
@@ -105,6 +119,153 @@ class OpenAiCompatibleLlmGatewayTest {
                 });
     }
 
+    @Test
+    void 
saturatedGatewayReturnsStructuredOverloadWithoutRunningProviderOnCaller() 
throws Exception {
+        ExecutorService executor = singleChatExecutor();
+        List<RecordingSseEmitter> emitters = new CopyOnWriteArrayList<>();
+        OpenAiCompatibleLlmGateway testedGateway = gateway(executor, emitters);
+        LlmConfigVO config = config("openai", "sk-test");
+        CountDownLatch started = new CountDownLatch(1);
+        CountDownLatch release = new CountDownLatch(1);
+        when(configService.getConfig()).thenReturn(config);
+        when(llmClient.supports(config)).thenReturn(true);
+        doAnswer(invocation -> {
+            started.countDown();
+            release.await();
+            return null;
+        }).when(llmClient).stream(any(), any(), any(), any());
+        try {
+            testedGateway.chat(ChatDTO.builder().message("first").build());
+            assertThat(started.await(5, TimeUnit.SECONDS)).isTrue();
+
+            CompletableFuture<SseEmitter> overloaded = 
CompletableFuture.supplyAsync(
+                    () -> 
testedGateway.chat(ChatDTO.builder().message("second").build()));
+            SseEmitter result = overloaded.get(1, TimeUnit.SECONDS);
+
+            assertThat(result).isSameAs(emitters.get(1));
+            assertThat(emitters.get(1).eventText())
+                    .contains("event:error", "llm.gateway.overloaded", "503", 
"event:done", "[DONE]");
+            assertThat(emitters.get(1).completed).isTrue();
+        } finally {
+            release.countDown();
+            testedGateway.destroy();
+        }
+    }
+
+    @Test
+    void 
downstreamTimeoutInterruptsTheRunningProviderWithoutSendingAnotherTerminalEvent()
 throws Exception {
+        ExecutorService executor = singleChatExecutor();
+        List<RecordingSseEmitter> emitters = new CopyOnWriteArrayList<>();
+        OpenAiCompatibleLlmGateway testedGateway = gateway(executor, emitters);
+        LlmConfigVO config = config("openai", "sk-test");
+        CountDownLatch started = new CountDownLatch(1);
+        CountDownLatch interrupted = new CountDownLatch(1);
+        when(configService.getConfig()).thenReturn(config);
+        when(llmClient.supports(config)).thenReturn(true);
+        doAnswer(invocation -> {
+            started.countDown();
+            try {
+                new CountDownLatch(1).await();
+            } catch (InterruptedException exception) {
+                interrupted.countDown();
+                Thread.currentThread().interrupt();
+            }
+            return null;
+        }).when(llmClient).stream(any(), any(), any(), any());
+        try {
+            testedGateway.chat(ChatDTO.builder().message("hello").build());
+            assertThat(started.await(5, TimeUnit.SECONDS)).isTrue();
+
+            emitters.get(0).triggerTimeout();
+
+            assertThat(interrupted.await(5, TimeUnit.SECONDS)).isTrue();
+            assertThat(emitters.get(0).sentEvents).isEmpty();
+        } finally {
+            testedGateway.destroy();
+        }
+    }
+
+    @Test
+    void gatewayShutdownInterruptsActiveProviderWork() throws Exception {
+        ExecutorService executor = singleChatExecutor();
+        List<RecordingSseEmitter> emitters = new CopyOnWriteArrayList<>();
+        OpenAiCompatibleLlmGateway testedGateway = gateway(executor, emitters);
+        LlmConfigVO config = config("openai", "sk-test");
+        CountDownLatch started = new CountDownLatch(1);
+        CountDownLatch interrupted = new CountDownLatch(1);
+        when(configService.getConfig()).thenReturn(config);
+        when(llmClient.supports(config)).thenReturn(true);
+        doAnswer(invocation -> {
+            started.countDown();
+            try {
+                new CountDownLatch(1).await();
+            } catch (InterruptedException exception) {
+                interrupted.countDown();
+                Thread.currentThread().interrupt();
+            }
+            return null;
+        }).when(llmClient).stream(any(), any(), any(), any());
+
+        testedGateway.chat(ChatDTO.builder().message("hello").build());
+        assertThat(started.await(5, TimeUnit.SECONDS)).isTrue();
+
+        testedGateway.destroy();
+
+        assertThat(interrupted.await(5, TimeUnit.SECONDS)).isTrue();
+        assertThat(emitters.get(0).sentEvents).isEmpty();
+    }
+
+    @Test
+    void successfulAndFailedStreamsEmitOneTerminalSequence() throws Exception {
+        ExecutorService executor = singleChatExecutor();
+        List<RecordingSseEmitter> emitters = new CopyOnWriteArrayList<>();
+        OpenAiCompatibleLlmGateway testedGateway = gateway(executor, emitters);
+        LlmConfigVO config = config("openai", "sk-test");
+        when(configService.getConfig()).thenReturn(config);
+        when(llmClient.supports(config)).thenReturn(true);
+        doAnswer(invocation -> {
+            @SuppressWarnings("unchecked")
+            Consumer<String> consumer = invocation.getArgument(3, 
Consumer.class);
+            consumer.accept("hello");
+            return null;
+        }).doThrow(new LlmGatewayException(502, "llm.provider.failed", 
"provider failed", "retry"))
+                .when(llmClient).stream(any(), any(), any(), any());
+        try {
+            testedGateway.chat(ChatDTO.builder().message("success").build());
+            assertThat(emitters.get(0).completedLatch.await(5, 
TimeUnit.SECONDS)).isTrue();
+            testedGateway.chat(ChatDTO.builder().message("failure").build());
+            assertThat(emitters.get(1).completedLatch.await(5, 
TimeUnit.SECONDS)).isTrue();
+
+            assertThat(emitters.get(0).eventText())
+                    .contains("event:message", "hello", "event:done", "[DONE]")
+                    .doesNotContain("event:error");
+            assertThat(emitters.get(1).eventText())
+                    .contains("event:error", "llm.provider.failed", 
"event:done", "[DONE]");
+            assertThat(emitters.get(0).eventCount("event:done")).isEqualTo(1);
+            assertThat(emitters.get(1).eventCount("event:done")).isEqualTo(1);
+        } finally {
+            testedGateway.destroy();
+        }
+    }
+
+    private OpenAiCompatibleLlmGateway gateway(ExecutorService executor,
+                                                List<RecordingSseEmitter> 
emitters) {
+        return new OpenAiCompatibleLlmGateway(
+                configService, llmClient, new 
AgentProviderRegistry(List.of()), new ObjectMapper(),
+                executor, timeout -> {
+                    RecordingSseEmitter emitter = new 
RecordingSseEmitter(timeout);
+                    emitters.add(emitter);
+                    return emitter;
+                });
+    }
+
+    private ExecutorService singleChatExecutor() {
+        return new ThreadPoolExecutor(
+                0, 1, 60L, TimeUnit.SECONDS,
+                new SynchronousQueue<>(),
+                new ThreadPoolExecutor.AbortPolicy());
+    }
+
     private LlmConfigVO config(String provider, String apiKey) {
         return LlmConfigVO.builder()
                 .provider(provider)
@@ -116,4 +277,70 @@ class OpenAiCompatibleLlmGatewayTest {
                 .enabled(true)
                 .build();
     }
+
+    private static final class RecordingSseEmitter extends SseEmitter {
+        private final List<Set<ResponseBodyEmitter.DataWithMediaType>> 
sentEvents = new CopyOnWriteArrayList<>();
+        private final CountDownLatch completedLatch = new CountDownLatch(1);
+        private Runnable completionCallback;
+        private Runnable timeoutCallback;
+        private Consumer<Throwable> errorCallback;
+        private boolean completed;
+
+        RecordingSseEmitter(long timeout) {
+            super(timeout);
+        }
+
+        @Override
+        public void send(SseEventBuilder builder) throws IOException {
+            sentEvents.add(builder.build());
+        }
+
+        @Override
+        public synchronized void onCompletion(Runnable callback) {
+            completionCallback = callback;
+        }
+
+        @Override
+        public synchronized void onTimeout(Runnable callback) {
+            timeoutCallback = callback;
+        }
+
+        @Override
+        public synchronized void onError(Consumer<Throwable> callback) {
+            errorCallback = callback;
+        }
+
+        @Override
+        public synchronized void complete() {
+            completed = true;
+            completedLatch.countDown();
+            if (completionCallback != null) {
+                completionCallback.run();
+            }
+        }
+
+        @Override
+        public synchronized void completeWithError(Throwable throwable) {
+            completedLatch.countDown();
+            if (errorCallback != null) {
+                errorCallback.accept(throwable);
+            }
+        }
+
+        void triggerTimeout() {
+            timeoutCallback.run();
+        }
+
+        String eventText() {
+            List<String> values = new ArrayList<>();
+            sentEvents.forEach(event -> event.forEach(item -> 
values.add(String.valueOf(item.getData()))));
+            return String.join("", values);
+        }
+
+        long eventCount(String marker) {
+            return sentEvents.stream()
+                    .filter(event -> event.stream().anyMatch(item -> 
String.valueOf(item.getData()).contains(marker)))
+                    .count();
+        }
+    }
 }

Reply via email to