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 3022a55c3 fix(rmqctl): keep stdio notifications flowing at call 
capacity (#6392)
3022a55c3 is described below

commit 3022a55c33f5dee5595988c25a446171d8c45f40
Author: pilichoumao <[email protected]>
AuthorDate: Thu Oct 15 18:09:25 2026 +0800

    fix(rmqctl): keep stdio notifications flowing at call capacity (#6392)
---
 docs/ai-agent-architecture.md           |   3 +
 rmqctl/cmd/mcp_stdio.go                 |   4 +-
 rmqctl/cmd/mcp_stdio_saturation_test.go | 271 ++++++++++++++++++++++++++++++++
 3 files changed, 277 insertions(+), 1 deletion(-)

diff --git a/docs/ai-agent-architecture.md b/docs/ai-agent-architecture.md
index c8b8766ed..ab01cd757 100644
--- a/docs/ai-agent-architecture.md
+++ b/docs/ai-agent-architecture.md
@@ -53,6 +53,9 @@ Agent 只是它的第一个客户。环路里最后一跳又回到了 Studio 自
 4. **进程级爆炸半径。** 工具访问由一个独立进程中介,该进程有自己的凭据作用域与实例绑定
    (`--instance-id` 烘进 argv,Agent 改不了),而不是 JVM 内部调用——后者可能被诱导越权。
 
+stdio 桥最多转发 64 个在途调用;满载时暂停读取后续输入,仍处理服务器通知、
+调用结果和取消。释放容量后继续转发排队请求,正常 EOF 会排空已有结果。
+
 ### 它没有买到什么
 
 - **延迟**:每次工具调用多一个进程 + 一次容器内 HTTP 跳。
diff --git a/rmqctl/cmd/mcp_stdio.go b/rmqctl/cmd/mcp_stdio.go
index 087197e36..357e4d7d6 100644
--- a/rmqctl/cmd/mcp_stdio.go
+++ b/rmqctl/cmd/mcp_stdio.go
@@ -148,7 +148,9 @@ func (p *stdioProxy) run(ctx context.Context) error {
                // Keep handling responses, server notifications and 
cancellation while a
                // handshake message is in flight; ordinary calls may run 
concurrently once
                // the initialized notification has been sent successfully.
-               if !p.ready && p.activeCalls > 0 {
+               // At capacity, defer stdin instead of blocking the loop on a 
call slot;
+               // notifications, results and cancellation must still be 
serviced.
+               if p.activeCalls >= maxConcurrentCalls || (!p.ready && 
p.activeCalls > 0) {
                        events = nil
                }
                select {
diff --git a/rmqctl/cmd/mcp_stdio_saturation_test.go 
b/rmqctl/cmd/mcp_stdio_saturation_test.go
new file mode 100644
index 000000000..db61a6203
--- /dev/null
+++ b/rmqctl/cmd/mcp_stdio_saturation_test.go
@@ -0,0 +1,271 @@
+/*
+ * 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 cmd
+
+import (
+       "bytes"
+       "context"
+       "encoding/json"
+       "fmt"
+       "io"
+       "net/http"
+       "net/http/httptest"
+       "strings"
+       "sync"
+       "sync/atomic"
+       "testing"
+       "time"
+
+       "github.com/apache/rocketmq-dashboard/rmqctl/internal/studio"
+)
+
+// Reaching EOF requires the scanner to have handed off all preceding lines.
+// With two queued requests and a one-event buffer, the old loop has consumed
+// the first queued event when EOF is reached; the corrected loop pauses input.
+type saturationInput struct {
+       *strings.Reader
+       eof  chan struct{}
+       once sync.Once
+}
+
+func (r *saturationInput) Read(p []byte) (int, error) {
+       n, err := r.Reader.Read(p)
+       if err == io.EOF {
+               r.once.Do(func() { close(r.eof) })
+       }
+       return n, err
+}
+
+type saturationOutput struct {
+       progress  chan struct{}
+       once      sync.Once
+       responses atomic.Int32
+}
+
+func (w *saturationOutput) Write(p []byte) (int, error) {
+       if bytes.Contains(p, []byte(`"notifications/progress"`)) {
+               w.once.Do(func() { close(w.progress) })
+       }
+       if bytes.Contains(p, []byte(`"result"`)) {
+               w.responses.Add(1)
+       }
+       return len(p), nil
+}
+
+func TestStdioSaturationForwardsHTTPProgress(t *testing.T) {
+       ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+       defer cancel()
+       release, notify := make(chan struct{}), make(chan struct{})
+       var releaseOnce, notifyOnce sync.Once
+       releaseCalls := func() { releaseOnce.Do(func() { close(release) }) }
+       sendNotification := func() { notifyOnce.Do(func() { close(notify) }) }
+       defer releaseCalls()
+       defer sendNotification()
+       var calls, running, peak atomic.Int32
+       saturated := make(chan struct{})
+       output := &saturationOutput{progress: make(chan struct{})}
+       server := httptest.NewServer(http.HandlerFunc(func(w 
http.ResponseWriter, r *http.Request) {
+               if r.Method == http.MethodDelete {
+                       w.WriteHeader(http.StatusNoContent)
+                       return
+               }
+               var req struct {
+                       ID     json.RawMessage `json:"id"`
+                       Method string          `json:"method"`
+               }
+               if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+                       t.Error(err)
+                       return
+               }
+               if req.Method == "notifications/initialized" {
+                       w.WriteHeader(http.StatusAccepted)
+                       return
+               }
+               if req.Method == "initialize" {
+                       w.Header().Set("Content-Type", "application/json")
+                       w.Header().Set("Mcp-Session-Id", "synthetic-session")
+                       fmt.Fprintf(w, 
`{"jsonrpc":"2.0","id":%s,"result":{"protocolVersion":"2025-11-25","capabilities":{},"serverInfo":{"name":"probe","version":"1"}}}`,
 req.ID)
+                       return
+               }
+               if req.Method != "tools/list" {
+                       t.Errorf("unexpected method %s", req.Method)
+                       return
+               }
+               current := running.Add(1)
+               for {
+                       previous := peak.Load()
+                       if current <= previous || peak.CompareAndSwap(previous, 
current) {
+                               break
+                       }
+               }
+               completed := false
+               defer func() {
+                       if !completed {
+                               running.Add(-1)
+                       }
+               }()
+               index := calls.Add(1)
+               if index == maxConcurrentCalls {
+                       close(saturated)
+               }
+               w.Header().Set("Content-Type", "text/event-stream")
+               w.WriteHeader(http.StatusOK)
+               w.(http.Flusher).Flush()
+               if index == 1 {
+                       select {
+                       case <-notify:
+                       case <-r.Context().Done():
+                               return
+                       }
+                       fmt.Fprint(w, "event: message\ndata: 
{\"jsonrpc\":\"2.0\",\"method\":\"notifications/progress\",\"params\":{\"progress\":1}}\n\n")
+                       w.(http.Flusher).Flush()
+               }
+               select {
+               case <-release:
+               case <-r.Context().Done():
+                       return
+               }
+               // Count a call as complete before publishing its terminal 
response, which
+               // permits the client to admit the next queued call immediately.
+               running.Add(-1)
+               completed = true
+               fmt.Fprintf(w, "event: message\ndata: 
{\"jsonrpc\":\"2.0\",\"id\":%s,\"result\":{\"tools\":[]}}\n\n", req.ID)
+       }))
+       defer func() {
+               cancel()
+               sendNotification()
+               releaseCalls()
+               server.Close()
+       }()
+       session, err := 
studio.NewClient(server.Client()).NewMCPClientSession(studio.Target{Server: 
server.URL, InstanceID: "test-instance", Credential: 
studio.Credential{AccessKey: "synthetic-ak", SecretKey: "synthetic-sk"}, 
Timeout: 4 * time.Second})
+       if err != nil {
+               t.Fatal(err)
+       }
+       var input strings.Builder
+       
input.WriteString(`{"jsonrpc":"2.0","id":1000,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"probe","version":"1"}}}`
 + "\n")
+       
input.WriteString(`{"jsonrpc":"2.0","method":"notifications/initialized"}` + 
"\n")
+       const requestCount = maxConcurrentCalls + 2
+       for i := 0; i < requestCount; i++ {
+               fmt.Fprintf(&input, 
`{"jsonrpc":"2.0","id":%d,"method":"tools/list"}`+"\n", i+1)
+       }
+       reader := &saturationInput{Reader: strings.NewReader(input.String()), 
eof: make(chan struct{})}
+       finished := make(chan error, 1)
+       stopped := make(chan struct{})
+       go func() { defer close(stopped); finished <- runStdioProxy(ctx, 
reader, output, io.Discard, session) }()
+       defer func() { cancel(); sendNotification(); releaseCalls(); <-stopped 
}()
+       select {
+       case <-saturated:
+       case <-ctx.Done():
+               t.Fatal("HTTP calls did not saturate")
+       }
+       // The EOF checkpoint establishes blocked dispatch on the baseline. A 
loop
+       // that correctly defers queued input remains paused, so use a bounded 
wait
+       // before testing notification delivery in that state, rather than a 
sleep.
+       select {
+       case <-reader.eof:
+       case <-time.After(100 * time.Millisecond):
+       }
+       sendNotification()
+       select {
+       case <-output.progress:
+       case <-time.After(time.Second):
+               t.Error("SSE progress blocked behind queued stdin at capacity")
+       }
+       if calls.Load() != maxConcurrentCalls {
+               t.Errorf("admitted %d requests before capacity was released", 
calls.Load())
+       }
+       releaseCalls()
+       select {
+       case err := <-finished:
+               if err != nil {
+                       t.Errorf("proxy: %v", err)
+               }
+       case <-ctx.Done():
+               t.Fatal("proxy did not drain queued calls")
+       }
+       if calls.Load() != requestCount {
+               t.Errorf("HTTP requests=%d, want %d", calls.Load(), 
requestCount)
+       }
+       if peak.Load() > maxConcurrentCalls {
+               t.Errorf("HTTP peak=%d, exceeds %d", peak.Load(), 
maxConcurrentCalls)
+       }
+       if output.responses.Load() != requestCount+1 {
+               t.Errorf("response frames=%d, want %d including initialize", 
output.responses.Load(), requestCount+1)
+       }
+       t.Logf("HTTP requests=%d, peak outstanding=%d, response frames=%d", 
calls.Load(), peak.Load(), output.responses.Load())
+}
+
+func TestStdioSaturationCancellationDrainsWorkers(t *testing.T) {
+       ctx, cancel := context.WithCancel(context.Background())
+       defer cancel()
+       session := newFakeMcpSession()
+       saturated := make(chan struct{})
+       var calls, running atomic.Int32
+       session.sendHandler = func(callCtx context.Context, payload 
json.RawMessage) (json.RawMessage, bool, error) {
+               var request struct {
+                       ID     json.RawMessage `json:"id"`
+                       Method string          `json:"method"`
+               }
+               if err := json.Unmarshal(payload, &request); err != nil {
+                       return nil, false, err
+               }
+               if request.Method == "initialize" {
+                       return 
json.RawMessage(`{"jsonrpc":"2.0","id":1000,"result":{}}`), true, nil
+               }
+               if request.Method == "notifications/initialized" {
+                       return nil, false, nil
+               }
+               running.Add(1)
+               defer running.Add(-1)
+               if calls.Add(1) == maxConcurrentCalls {
+                       close(saturated)
+               }
+               <-callCtx.Done()
+               return nil, false, callCtx.Err()
+       }
+       var input strings.Builder
+       input.WriteString(`{"jsonrpc":"2.0","id":1000,"method":"initialize"}` + 
"\n" + `{"jsonrpc":"2.0","method":"notifications/initialized"}` + "\n")
+       for i := 0; i < maxConcurrentCalls+2; i++ {
+               fmt.Fprintf(&input, 
`{"jsonrpc":"2.0","id":%d,"method":"tools/list"}`+"\n", i+1)
+       }
+       finished := make(chan error, 1)
+       stopped := make(chan struct{})
+       go func() {
+               defer close(stopped)
+               finished <- runStdioProxy(ctx, 
strings.NewReader(input.String()), io.Discard, io.Discard, session)
+       }()
+       defer func() { cancel(); <-stopped }()
+       select {
+       case <-saturated:
+       case <-time.After(2 * time.Second):
+               cancel()
+               <-finished
+               t.Fatal("calls did not saturate")
+       }
+       cancel()
+       select {
+       case err := <-finished:
+               if err != nil {
+                       t.Errorf("cancelled proxy: %v", err)
+               }
+       case <-time.After(2 * time.Second):
+               t.Fatal("workers did not drain after cancellation")
+       }
+       if running.Load() != 0 || !session.closed {
+               t.Errorf("workers=%d closed=%v", running.Load(), session.closed)
+       }
+}

Reply via email to