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 b88aedc34 fix(rmqctl): reconnect on session termination even when the 
id was cleared (#4876)
b88aedc34 is described below

commit b88aedc34949e2da81f9fb862572b8f879627a13
Author: Apulupie <[email protected]>
AuthorDate: Mon Oct 19 18:14:16 2026 +0800

    fix(rmqctl): reconnect on session termination even when the id was cleared 
(#4876)
    
    Verified against `rocketmq-studio` at 86aa49c34 before merging: 缺陷仍在。主干 
`rmqctl/internal/studio/mcp_message.go:196` 仍是 `return generation, 
session.transport.GetSessionId() != ""`,`:177` 用它决定是否重连;`MB..trunk` 对 `rmqc
    
    merge-tree clean against the current trunk and no outstanding must-fix 
items; merged as-is (squash) per the maintainer review of 2026-10-19.
---
 rmqctl/internal/studio/mcp_message.go      |  17 +--
 rmqctl/internal/studio/mcp_message_test.go | 168 +++++++++++++++++++++++++++++
 2 files changed, 179 insertions(+), 6 deletions(-)

diff --git a/rmqctl/internal/studio/mcp_message.go 
b/rmqctl/internal/studio/mcp_message.go
index 7c4d7a6b7..41f3d2360 100644
--- a/rmqctl/internal/studio/mcp_message.go
+++ b/rmqctl/internal/studio/mcp_message.go
@@ -161,7 +161,7 @@ func (session *MCPClientSession) sendWithReconnect(ctx 
context.Context, skipReco
                if err := session.sendGate.acquire(ctx, session.closed, false); 
err != nil {
                        return err
                }
-               generation, hadSession := session.sendSnapshot()
+               generation, canReconnect := session.sendSnapshot()
                session.state.RLock()
                pending := session.state.reconnectPending
                session.state.RUnlock()
@@ -174,7 +174,7 @@ func (session *MCPClientSession) sendWithReconnect(ctx 
context.Context, skipReco
                if err == nil || skipReconnect || attempt == 1 {
                        return err
                }
-               if !pending && !(hadSession && errors.Is(err, 
mcptransport.ErrSessionTerminated)) {
+               if !pending && !(canReconnect && errors.Is(err, 
mcptransport.ErrSessionTerminated)) {
                        return err
                }
                if reconnectErr := session.reinitialize(ctx, generation); 
reconnectErr != nil {
@@ -186,14 +186,19 @@ func (session *MCPClientSession) sendWithReconnect(ctx 
context.Context, skipReco
        return err
 }
 
-// sendSnapshot returns the current session generation and whether a session
-// id is already established. Callers must hold a sendGate permit so the
-// snapshot is consistent with the transport state used for the send.
+// sendSnapshot returns the current session generation and whether a terminated
+// session can be re-initialized, which is possible whenever the initialize
+// request has been recorded. The transport's session id cannot be used for
+// this decision: mcp-go clears it as soon as any request fails with a 404, so
+// a sender racing with that failure would wrongly see "no session" and skip
+// the reconnect. Callers must hold a sendGate permit so the snapshot is
+// consistent with the transport state used for the send.
 func (session *MCPClientSession) sendSnapshot() (uint64, bool) {
        session.state.RLock()
        generation := session.state.generation
+       initialize := session.state.initialize
        session.state.RUnlock()
-       return generation, session.transport.GetSessionId() != ""
+       return generation, initialize != nil
 }
 
 func (session *MCPClientSession) recordInitialization(
diff --git a/rmqctl/internal/studio/mcp_message_test.go 
b/rmqctl/internal/studio/mcp_message_test.go
index aee3b0542..06fddf724 100644
--- a/rmqctl/internal/studio/mcp_message_test.go
+++ b/rmqctl/internal/studio/mcp_message_test.go
@@ -17,11 +17,179 @@
 package studio
 
 import (
+       "context"
        "encoding/json"
        "strings"
+       "sync"
        "testing"
+
+       mcptransport "github.com/mark3labs/mcp-go/client/transport"
+       "github.com/mark3labs/mcp-go/mcp"
 )
 
+// reconnectStubTransport answers requests the way a Studio server that lost a
+// session does: every non-initialize request fails with a 404 until a fresh
+// initialize exchange establishes "session-2". Like mcp-go's StreamableHTTP
+// client, it clears the stored session id when a request fails with a 404.
+type reconnectStubTransport struct {
+       mu          sync.Mutex
+       sessionID   string
+       sends       int
+       initializes int
+}
+
+func newReconnectStubTransport(sessionID string) *reconnectStubTransport {
+       return &reconnectStubTransport{sessionID: sessionID}
+}
+
+func (transport *reconnectStubTransport) Start(context.Context) error { return 
nil }
+func (transport *reconnectStubTransport) Close() error                { return 
nil }
+
+func (transport *reconnectStubTransport) SetNotificationHandler(
+       func(mcp.JSONRPCNotification),
+) {
+}
+
+func (transport *reconnectStubTransport) SetProtocolVersion(string) {}
+
+func (transport *reconnectStubTransport) GetSessionId() string {
+       transport.mu.Lock()
+       defer transport.mu.Unlock()
+       return transport.sessionID
+}
+
+func (transport *reconnectStubTransport) SendRequest(
+       _ context.Context,
+       request mcptransport.JSONRPCRequest,
+) (*mcptransport.JSONRPCResponse, error) {
+       transport.mu.Lock()
+       if request.Method == string(mcp.MethodInitialize) {
+               transport.initializes++
+               transport.sessionID = "session-2"
+               transport.mu.Unlock()
+               return &mcptransport.JSONRPCResponse{
+                       JSONRPC: mcp.JSONRPC_VERSION,
+                       ID:      request.ID,
+                       Result:  json.RawMessage(`{"protocolVersion":"` + 
mcp.LATEST_PROTOCOL_VERSION + `"}`),
+               }, nil
+       }
+       transport.sends++
+       established := transport.sessionID == "session-2"
+       used := transport.sessionID
+       transport.mu.Unlock()
+       if !established {
+               // mcp-go clears the session id on a 404, but only when it 
still holds
+               // the value the failed request was sent with (CompareAndSwap).
+               transport.mu.Lock()
+               if transport.sessionID == used {
+                       transport.sessionID = ""
+               }
+               transport.mu.Unlock()
+               return nil, mcptransport.ErrSessionTerminated
+       }
+       return &mcptransport.JSONRPCResponse{
+               JSONRPC: mcp.JSONRPC_VERSION,
+               ID:      request.ID,
+               Result:  json.RawMessage(`{}`),
+       }, nil
+}
+
+func (transport *reconnectStubTransport) SendNotification(
+       context.Context,
+       mcp.JSONRPCNotification,
+) error {
+       return nil
+}
+
+func (transport *reconnectStubTransport) initializeRequest() 
*mcptransport.JSONRPCRequest {
+       return &mcptransport.JSONRPCRequest{
+               JSONRPC: mcp.JSONRPC_VERSION,
+               ID:      mcp.NewRequestId("initialize-1"),
+               Method:  string(mcp.MethodInitialize),
+       }
+}
+
+func newReconnectTestSession(transport *reconnectStubTransport) 
*MCPClientSession {
+       session := &MCPClientSession{
+               transport:     transport,
+               closed:        make(chan struct{}),
+               notifications: make(chan json.RawMessage, 
notificationBufferSize),
+       }
+       session.state.initialize = transport.initializeRequest()
+       return session
+}
+
+func sendToolsList(session *MCPClientSession) error {
+       return session.sendWithReconnect(context.Background(), false, func() 
error {
+               _, err := session.transport.SendRequest(context.Background(), 
mcptransport.JSONRPCRequest{
+                       JSONRPC: mcp.JSONRPC_VERSION,
+                       ID:      mcp.NewRequestId("send-1"),
+                       Method:  string(mcp.MethodToolsList),
+               })
+               return err
+       })
+}
+
+// TestSendWithReconnectRetriesAfterConcurrentSessionClear reproduces the
+// concurrent-404 race: mcp-go clears the transport session id as soon as any
+// request fails with a 404, so another in-flight sender can observe an empty
+// session id in its snapshot even though a session had been established and
+// recorded. Reconnect-worthiness must therefore come from the recorded
+// initialize request, not from the transport's session id.
+func TestSendWithReconnectRetriesAfterConcurrentSessionClear(t *testing.T) {
+       transport := newReconnectStubTransport("")
+       session := newReconnectTestSession(transport)
+
+       // A concurrent sender just failed with a 404, which cleared the 
transport
+       // session id before that sender managed to reinitialize.
+       if err := sendToolsList(session); err != nil {
+               t.Fatalf("sendWithReconnect after a concurrent 404 cleared the 
session: %v", err)
+       }
+       transport.mu.Lock()
+       initializes := transport.initializes
+       transport.mu.Unlock()
+       if initializes != 1 {
+               t.Fatalf("initialize exchanges = %d, want 1", initializes)
+       }
+       if transport.GetSessionId() != "session-2" {
+               t.Fatalf("session id = %q, want session-2", 
transport.GetSessionId())
+       }
+}
+
+// TestSendWithReconnectConcurrent404sAllRecover verifies that concurrent
+// senders hitting a terminated session all recover once one of them
+// reinitializes, without duplicating the initialize exchange per sender.
+func TestSendWithReconnectConcurrent404sAllRecover(t *testing.T) {
+       transport := newReconnectStubTransport("session-1")
+       session := newReconnectTestSession(transport)
+
+       const senders = 8
+       var wg sync.WaitGroup
+       errs := make([]error, senders)
+       for i := range errs {
+               wg.Add(1)
+               go func() {
+                       defer wg.Done()
+                       errs[i] = sendToolsList(session)
+               }()
+       }
+       wg.Wait()
+       for i, err := range errs {
+               if err != nil {
+                       t.Fatalf("sender %d: %v", i, err)
+               }
+       }
+       transport.mu.Lock()
+       initializes := transport.initializes
+       transport.mu.Unlock()
+       // At least one initialize exchange must have re-established the 
session;
+       // a sender whose request raced the establishment may legitimately 
repeat
+       // it, but each sender must still recover.
+       if initializes < 1 {
+               t.Fatalf("initialize exchanges = %d, want at least 1", 
initializes)
+       }
+}
+
 func toolCallPayload(t *testing.T, tool string, arguments map[string]any) 
[]byte {
        t.Helper()
        params := map[string]any{"name": tool}

Reply via email to