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 c01908beb fix(rmqctl): respect cancellation while waiting for MCP
session access (#6604)
c01908beb is described below
commit c01908bebdc58c1341e4308220971f7a2441b606
Author: pilichoumao <[email protected]>
AuthorDate: Thu Oct 15 18:16:00 2026 +0800
fix(rmqctl): respect cancellation while waiting for MCP session access
(#6604)
---
docs/ai-agent-architecture.md | 8 +
.../internal/studio/mcp_gate_integration_test.go | 227 +++++++++++++++++++++
rmqctl/internal/studio/mcp_http.go | 6 +-
rmqctl/internal/studio/mcp_message.go | 14 +-
rmqctl/internal/studio/send_gate.go | 134 ++++++++++++
rmqctl/internal/studio/send_gate_test.go | 209 +++++++++++++++++++
6 files changed, 590 insertions(+), 8 deletions(-)
diff --git a/docs/ai-agent-architecture.md b/docs/ai-agent-architecture.md
index ab01cd757..caa28fa17 100644
--- a/docs/ai-agent-architecture.md
+++ b/docs/ai-agent-architecture.md
@@ -62,6 +62,14 @@ stdio 桥最多转发 64 个在途调用;满载时暂停读取后续输入,
- **独立性**:Studio 挂了 `rmqctl` 就无用。
- **隔离**:工具最终仍在拉起 Agent 的同一个 JVM 与同一个容器里执行,没有租户或网络隔离。
+### MCP 请求取消与会话重连
+
+`rmqctl` 的单次消息超时包含等待发送和重连的时间。普通请求可以并发发送;重连在已有发送完成后
+独占会话,完成 `initialize` 和 `notifications/initialized` 才允许后续请求进入新会话。
+等待队列按到达顺序放行,连续的普通请求可一起进入,后来的请求不能越过已排队的重连。
+等待中的请求取消或超时后会退出队列,不会迟到发送;关闭会话也会唤醒等待者。
+取消一条等待中的请求不会取消另一条正常调用或正在执行的重连,也不改变既有的重试次数和代际检查。
+
### 已否决的替代方案
把 MCP server 直接以 `{"type":"http","url":"http://127.0.0.1:8888/api/mcp"}` 注册进
diff --git a/rmqctl/internal/studio/mcp_gate_integration_test.go
b/rmqctl/internal/studio/mcp_gate_integration_test.go
new file mode 100644
index 000000000..f1aa20238
--- /dev/null
+++ b/rmqctl/internal/studio/mcp_gate_integration_test.go
@@ -0,0 +1,227 @@
+/*
+ * 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 studio
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "sync/atomic"
+ "testing"
+ "time"
+)
+import mcptransport "github.com/mark3labs/mcp-go/client/transport"
+
+func TestSessionDeadlineWhileReconnectOwnsSendLock(t *testing.T) {
+ var initCount atomic.Int32
+ entered := make(chan struct{})
+ release := make(chan struct{})
+ client := NewClient(&http.Client{Transport: roundTripFunc(func(req
*http.Request) (*http.Response, error) {
+ if req.Method == http.MethodDelete {
+ return mcpHTTPResponse(204, "", ""), nil
+ }
+ payload := readMCPPayload(t, req)
+ switch payload.Method {
+ case "initialize":
+ n := initCount.Add(1)
+ if n == 2 {
+ close(entered)
+ select {
+ case <-release:
+ case <-req.Context().Done():
+ return nil, req.Context().Err()
+ }
+ }
+ resp := mcpJSONResultResponse(payload.ID,
map[string]any{"protocolVersion": "2025-11-25", "capabilities":
map[string]any{}, "serverInfo": map[string]any{"name": "test", "version": "1"}})
+ resp.Header.Set(mcptransport.HeaderKeySessionID,
fmt.Sprintf("session-%d", n))
+ return resp, nil
+ case "notifications/initialized":
+ return mcpHTTPResponse(202, "", ""), nil
+ case "tools/list":
+ if req.Header.Get(mcptransport.HeaderKeySessionID) ==
"session-1" {
+ return mcpHTTPResponse(404, "text/plain",
"expired"), nil
+ }
+ return mcpJSONResultResponse(payload.ID,
map[string]any{"tools": []any{}}), nil
+ }
+ return nil, fmt.Errorf("unexpected method %s", payload.Method)
+ })})
+ session := newMCPTestSession(t, client, Target{Server:
"http://localhost", InstanceID: "dev", Credential: Credential{AccessKey:
"test", SecretKey: "test"}, Timeout: 5 * time.Second})
+ defer session.Close()
+ initializeClientSession(t, context.Background(), session)
+ firstDone := make(chan error, 1)
+ go func() {
+ _, _, err := session.SendMessage(context.Background(),
json.RawMessage(`{"jsonrpc":"2.0","id":"first","method":"tools/list"}`))
+ firstDone <- err
+ }()
+ select {
+ case <-entered:
+ case <-time.After(time.Second):
+ t.Fatal("reconnect did not start")
+ }
+ ctx, cancel := context.WithTimeout(context.Background(),
30*time.Millisecond)
+ defer cancel()
+ done := make(chan error, 1)
+ go func() {
+ _, _, err := session.SendMessage(ctx,
json.RawMessage(`{"jsonrpc":"2.0","id":"second","method":"tools/list"}`))
+ done <- err
+ }()
+ gotDone := false
+ select {
+ case err := <-done:
+ gotDone = true
+ if !errors.Is(err, context.DeadlineExceeded) {
+ t.Errorf("unexpected cancellation error %v", err)
+ }
+ case <-time.After(time.Second):
+ t.Error("cancelled request remained blocked by another
request's reconnect")
+ }
+ close(release)
+ select {
+ case err := <-firstDone:
+ if err != nil {
+ t.Errorf("healthy reconnect failed: %v", err)
+ }
+ case <-time.After(time.Second):
+ t.Error("healthy request stalled")
+ }
+ if !gotDone {
+ select {
+ case <-done:
+ case <-time.After(time.Second):
+ t.Error("cancelled call never returned")
+ }
+ }
+}
+
+func TestSessionDeadlineWhileAnotherCallOwnsReadLock(t *testing.T) {
+ entered := make(chan struct{})
+ release := make(chan struct{})
+ client := NewClient(&http.Client{Transport: roundTripFunc(func(req
*http.Request) (*http.Response, error) {
+ if req.Method == http.MethodDelete {
+ return mcpHTTPResponse(204, "", ""), nil
+ }
+ payload := readMCPPayload(t, req)
+ switch payload.Method {
+ case "initialize":
+ resp := mcpJSONResultResponse(payload.ID,
map[string]any{"protocolVersion": "2025-11-25", "capabilities":
map[string]any{}, "serverInfo": map[string]any{"name": "test", "version": "1"}})
+ resp.Header.Set(mcptransport.HeaderKeySessionID,
"session-1")
+ return resp, nil
+ case "notifications/initialized":
+ return mcpHTTPResponse(202, "", ""), nil
+ case "tools/list":
+ if string(payload.ID) == `"long"` {
+ close(entered)
+ select {
+ case <-release:
+ case <-req.Context().Done():
+ return nil, req.Context().Err()
+ }
+ return mcpJSONResultResponse(payload.ID,
map[string]any{"tools": []any{}}), nil
+ }
+ return mcpHTTPResponse(404, "text/plain", "expired"),
nil
+ }
+ return nil, fmt.Errorf("unexpected method %s", payload.Method)
+ })})
+ session := newMCPTestSession(t, client, Target{Server:
"http://localhost", InstanceID: "dev", Credential: Credential{AccessKey:
"test", SecretKey: "test"}, Timeout: 5 * time.Second})
+ defer session.Close()
+ initializeClientSession(t, context.Background(), session)
+ longDone := make(chan error, 1)
+ go func() {
+ _, _, err := session.SendMessage(context.Background(),
json.RawMessage(`{"jsonrpc":"2.0","id":"long","method":"tools/list"}`))
+ longDone <- err
+ }()
+ select {
+ case <-entered:
+ case <-time.After(time.Second):
+ t.Fatal("long request did not start")
+ }
+ ctx, cancel := context.WithTimeout(context.Background(),
30*time.Millisecond)
+ defer cancel()
+ done := make(chan error, 1)
+ go func() {
+ _, _, err := session.SendMessage(ctx,
json.RawMessage(`{"jsonrpc":"2.0","id":"short","method":"tools/list"}`))
+ done <- err
+ }()
+ gotDone := false
+ select {
+ case err := <-done:
+ gotDone = true
+ if !errors.Is(err, context.DeadlineExceeded) {
+ t.Errorf("unexpected cancellation error %v", err)
+ }
+ case <-time.After(time.Second):
+ t.Error("reconnect lock acquisition ignored the caller
deadline")
+ }
+ close(release)
+ select {
+ case err := <-longDone:
+ if err != nil {
+ t.Errorf("healthy long request failed: %v", err)
+ }
+ case <-time.After(time.Second):
+ t.Error("long request stalled")
+ }
+ if !gotDone {
+ select {
+ case <-done:
+ case <-time.After(time.Second):
+ t.Error("cancelled call never returned")
+ }
+ }
+}
+
+func TestSessionConfiguredTimeoutAndCloseWhileQueued(t *testing.T) {
+ for _, method := range []string{"request", "notification"} {
+ for _, termination := range []string{"timeout", "close"} {
+ t.Run(method+"/"+termination, func(t *testing.T) {
+ transport := &reconnectFailureTransport{}
+ session := &MCPClientSession{transport:
transport, closed: make(chan struct{})}
+ if termination == "timeout" {
+ session.timeout = 30 * time.Millisecond
+ }
+ if err :=
session.sendGate.acquire(context.Background(), session.closed, true); err !=
nil {
+ t.Fatal(err)
+ }
+ defer session.sendGate.release(true)
+ defer session.Close()
+ payload :=
json.RawMessage(`{"jsonrpc":"2.0","id":"queued","method":"tools/list"}`)
+ if method == "notification" {
+ payload =
json.RawMessage(`{"jsonrpc":"2.0","method":"notifications/test"}`)
+ }
+ done := make(chan error, 1)
+ go func() { _, _, err :=
session.SendMessage(context.Background(), payload); done <- err }()
+ waitSendGateQueue(t, &session.sendGate, 1)
+ want := error(context.DeadlineExceeded)
+ if termination == "close" {
+ want = errMCPClientSessionClosed
+ if err := session.Close(); err != nil {
+ t.Fatal(err)
+ }
+ }
+ receiveSendGateResult(t, done, want)
+ transport.mu.Lock()
+ ordinary, notifications :=
transport.ordinaryCalls, transport.notificationCalls
+ transport.mu.Unlock()
+ if ordinary != 0 || notifications != 0 {
+ t.Fatalf("queued message was sent:
calls=%d notifications=%d", ordinary, notifications)
+ }
+ })
+ }
+ }
+}
diff --git a/rmqctl/internal/studio/mcp_http.go
b/rmqctl/internal/studio/mcp_http.go
index f8a1bb3af..78307a619 100644
--- a/rmqctl/internal/studio/mcp_http.go
+++ b/rmqctl/internal/studio/mcp_http.go
@@ -53,11 +53,11 @@ type MCPClientSession struct {
closed chan struct{}
notifications chan json.RawMessage
- // sendMu allows ordinary messages to be concurrent while preventing any
+ // sendGate allows ordinary messages to be concurrent while preventing
any
// message from entering a replacement session before reinitialization
and
// its initialized notification have completed.
- sendMu sync.RWMutex
- state struct {
+ sendGate sendGate
+ state struct {
sync.RWMutex
initialize *mcptransport.JSONRPCRequest
generation uint64
diff --git a/rmqctl/internal/studio/mcp_message.go
b/rmqctl/internal/studio/mcp_message.go
index 8babade41..7c4d7a6b7 100644
--- a/rmqctl/internal/studio/mcp_message.go
+++ b/rmqctl/internal/studio/mcp_message.go
@@ -158,7 +158,9 @@ func (session *MCPClientSession) sendWithReconnect(ctx
context.Context, skipReco
// A call may recover once and send at most twice. Only a definitive
session
// termination permits replay; an incomplete handshake has not sent the
call.
for attempt := 0; attempt < 2; attempt++ {
- session.sendMu.RLock()
+ if err := session.sendGate.acquire(ctx, session.closed, false);
err != nil {
+ return err
+ }
generation, hadSession := session.sendSnapshot()
session.state.RLock()
pending := session.state.reconnectPending
@@ -168,7 +170,7 @@ func (session *MCPClientSession) sendWithReconnect(ctx
context.Context, skipReco
} else {
err = send()
}
- session.sendMu.RUnlock()
+ session.sendGate.release(false)
if err == nil || skipReconnect || attempt == 1 {
return err
}
@@ -185,7 +187,7 @@ func (session *MCPClientSession) sendWithReconnect(ctx
context.Context, skipReco
}
// sendSnapshot returns the current session generation and whether a session
-// id is already established. Callers must hold sendMu (at least RLock) so the
+// id is already established. 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()
@@ -235,8 +237,10 @@ func initializationProtocolVersion(response
*mcptransport.JSONRPCResponse) (stri
func (session *MCPClientSession) reinitialize(ctx context.Context,
expectedGeneration uint64) error {
// A 404 for an established session requires a fresh initialize
exchange.
// MCP 2025-11-25:
https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management
- session.sendMu.Lock()
- defer session.sendMu.Unlock()
+ if err := session.sendGate.acquire(ctx, session.closed, true); err !=
nil {
+ return err
+ }
+ defer session.sendGate.release(true)
session.state.RLock()
if session.state.generation != expectedGeneration &&
!session.state.reconnectPending {
session.state.RUnlock()
diff --git a/rmqctl/internal/studio/send_gate.go
b/rmqctl/internal/studio/send_gate.go
new file mode 100644
index 000000000..b1e0f3e63
--- /dev/null
+++ b/rmqctl/internal/studio/send_gate.go
@@ -0,0 +1,134 @@
+/*
+ * 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 studio
+
+import (
+ "container/list"
+ "context"
+ "errors"
+ "sync"
+)
+
+var errMCPClientSessionClosed = errors.New("MCP client session is closed")
+
+// sendGate admits concurrent ordinary sends, but grants reconnects
exclusively.
+// A FIFO queue prevents new sends from starving a reconnect. Its zero value is
+// ready to use; waiting needs neither a polling timer nor a helper goroutine.
+type sendGate struct {
+ mu sync.Mutex
+ readers int
+ writer bool
+ waiters list.List
+}
+
+type sendGateWaiter struct {
+ exclusive bool
+ ready chan struct{}
+ granted bool
+}
+
+func sendGateWaitError(ctx context.Context, closed <-chan struct{}) error {
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ select {
+ case <-closed:
+ return errMCPClientSessionClosed
+ default:
+ return nil
+ }
+}
+
+func (gate *sendGate) acquire(ctx context.Context, closed <-chan struct{},
exclusive bool) error {
+ gate.mu.Lock()
+ if err := sendGateWaitError(ctx, closed); err != nil {
+ gate.mu.Unlock()
+ return err
+ }
+ if gate.waiters.Len() == 0 && !gate.writer && (!exclusive ||
gate.readers == 0) {
+ if exclusive {
+ gate.writer = true
+ } else {
+ gate.readers++
+ }
+ gate.mu.Unlock()
+ return nil
+ }
+ waiter := &sendGateWaiter{exclusive: exclusive, ready: make(chan
struct{})}
+ element := gate.waiters.PushBack(waiter)
+ gate.mu.Unlock()
+
+ select {
+ case <-waiter.ready:
+ case <-ctx.Done():
+ case <-closed:
+ }
+ gate.mu.Lock()
+ defer gate.mu.Unlock()
+ // Cancellation can race with a grant. Return the permit in that case
rather
+ // than leaving a reader or writer behind that blocks every subsequent
call.
+ if err := sendGateWaitError(ctx, closed); err != nil {
+ if waiter.granted {
+ gate.releaseLocked(exclusive)
+ } else {
+ gate.waiters.Remove(element)
+ gate.grantLocked()
+ }
+ return err
+ }
+ return nil
+}
+
+func (gate *sendGate) release(exclusive bool) {
+ gate.mu.Lock()
+ defer gate.mu.Unlock()
+ gate.releaseLocked(exclusive)
+}
+
+func (gate *sendGate) releaseLocked(exclusive bool) {
+ if exclusive {
+ gate.writer = false
+ } else {
+ gate.readers--
+ }
+ gate.grantLocked()
+}
+
+// Grant an exclusive waiter only after existing readers finish, or admit the
+// consecutive readers at the head of the queue as one concurrent batch.
+func (gate *sendGate) grantLocked() {
+ if gate.writer {
+ return
+ }
+ for element := gate.waiters.Front(); element != nil; element =
gate.waiters.Front() {
+ waiter := element.Value.(*sendGateWaiter)
+ if waiter.exclusive && gate.readers != 0 {
+ return
+ }
+ gate.waiters.Remove(element)
+ waiter.granted = true
+ if waiter.exclusive {
+ gate.writer = true
+ } else {
+ gate.readers++
+ }
+ close(waiter.ready)
+ if waiter.exclusive {
+ return
+ }
+ }
+}
diff --git a/rmqctl/internal/studio/send_gate_test.go
b/rmqctl/internal/studio/send_gate_test.go
new file mode 100644
index 000000000..438e1b2a2
--- /dev/null
+++ b/rmqctl/internal/studio/send_gate_test.go
@@ -0,0 +1,209 @@
+/*
+ * 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 studio
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "time"
+)
+
+func waitSendGateQueue(t *testing.T, gate *sendGate, count int) {
+ t.Helper()
+ deadline := time.After(2 * time.Second)
+ ticker := time.NewTicker(time.Millisecond)
+ defer ticker.Stop()
+ for {
+ gate.mu.Lock()
+ size := gate.waiters.Len()
+ gate.mu.Unlock()
+ if size == count {
+ return
+ }
+ select {
+ case <-deadline:
+ t.Fatalf("queued waiters = %d, want %d", size, count)
+ case <-ticker.C:
+ }
+ }
+}
+
+func acquireSendGateAsync(gate *sendGate, ctx context.Context, closed <-chan
struct{}, exclusive bool) <-chan error {
+ result := make(chan error, 1)
+ go func() { result <- gate.acquire(ctx, closed, exclusive) }()
+ return result
+}
+
+func receiveSendGateResult(t *testing.T, result <-chan error, want error) {
+ t.Helper()
+ select {
+ case err := <-result:
+ if !errors.Is(err, want) {
+ t.Fatalf("acquire error = %v, want %v", err, want)
+ }
+ case <-time.After(2 * time.Second):
+ t.Fatal("gate waiter did not return")
+ }
+}
+
+func TestSendGateConcurrentReadersAndFIFOReconnects(t *testing.T) {
+ var gate sendGate
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ closed := make(chan struct{})
+ if err := gate.acquire(ctx, closed, false); err != nil {
+ t.Fatal(err)
+ }
+ // A second ordinary send can overlap the first one.
+ if err := gate.acquire(ctx, closed, false); err != nil {
+ t.Fatal(err)
+ }
+ writer1 := acquireSendGateAsync(&gate, ctx, closed, true)
+ waitSendGateQueue(t, &gate, 1)
+ reader1 := acquireSendGateAsync(&gate, ctx, closed, false)
+ waitSendGateQueue(t, &gate, 2)
+ reader2 := acquireSendGateAsync(&gate, ctx, closed, false)
+ waitSendGateQueue(t, &gate, 3)
+ writer2 := acquireSendGateAsync(&gate, ctx, closed, true)
+ waitSendGateQueue(t, &gate, 4)
+ lateReader := acquireSendGateAsync(&gate, ctx, closed, false)
+ waitSendGateQueue(t, &gate, 5)
+ gate.release(false)
+ select {
+ case <-writer1:
+ t.Fatal("writer overlapped a remaining reader")
+ default:
+ }
+ gate.release(false)
+ receiveSendGateResult(t, writer1, nil)
+ select {
+ case <-reader1:
+ t.Fatal("reader overtook reconnect")
+ default:
+ }
+ gate.release(true)
+ receiveSendGateResult(t, reader1, nil)
+ receiveSendGateResult(t, reader2, nil)
+ gate.release(false)
+ select {
+ case <-writer2:
+ t.Fatal("writer overlapped reader batch")
+ default:
+ }
+ gate.release(false)
+ receiveSendGateResult(t, writer2, nil)
+ select {
+ case <-lateReader:
+ t.Fatal("late reader overtook queued reconnect")
+ default:
+ }
+ gate.release(true)
+ receiveSendGateResult(t, lateReader, nil)
+ gate.release(false)
+}
+
+func TestSendGateCancelledWriterUnblocksQueuedReaders(t *testing.T) {
+ var gate sendGate
+ closed := make(chan struct{})
+ if err := gate.acquire(context.Background(), closed, false); err != nil
{
+ t.Fatal(err)
+ }
+ writerCtx, cancel := context.WithCancel(context.Background())
+ writer := acquireSendGateAsync(&gate, writerCtx, closed, true)
+ waitSendGateQueue(t, &gate, 1)
+ readerCtx, readerCancel := context.WithTimeout(context.Background(),
2*time.Second)
+ defer readerCancel()
+ reader := acquireSendGateAsync(&gate, readerCtx, closed, false)
+ waitSendGateQueue(t, &gate, 2)
+ cancel()
+ receiveSendGateResult(t, writer, context.Canceled)
+ // The original reader is still active: removal must wake compatible
waiters.
+ receiveSendGateResult(t, reader, nil)
+ gate.release(false)
+ gate.release(false)
+ waitSendGateQueue(t, &gate, 0)
+}
+
+func TestSendGateCancelledGrantReturnsPermit(t *testing.T) {
+ for _, exclusive := range []bool{false, true} {
+ name := "reader"
+ if exclusive {
+ name = "writer"
+ }
+ t.Run(name, func(t *testing.T) {
+ for i := 0; i < 100; i++ {
+ var gate sendGate
+ closed := make(chan struct{})
+ if err := gate.acquire(context.Background(),
closed, true); err != nil {
+ t.Fatal(err)
+ }
+ ctx, cancel :=
context.WithCancel(context.Background())
+ result := acquireSendGateAsync(&gate, ctx,
closed, exclusive)
+ waitSendGateQueue(t, &gate, 1)
+ // Grant the permit, but cancel before the
waiter can finish acquiring it.
+ gate.mu.Lock()
+ gate.releaseLocked(true)
+ cancel()
+ gate.mu.Unlock()
+ receiveSendGateResult(t, result,
context.Canceled)
+ nextCtx, nextCancel :=
context.WithTimeout(context.Background(), time.Second)
+ err := gate.acquire(nextCtx, closed, true)
+ nextCancel()
+ if err != nil {
+ t.Fatalf("iteration %d leaked a granted
permit: %v", i, err)
+ }
+ gate.release(true)
+ }
+ })
+ }
+}
+
+func TestSendGateCloseWakesAllWaiters(t *testing.T) {
+ var gate sendGate
+ closed := make(chan struct{})
+ if err := gate.acquire(context.Background(), closed, true); err != nil {
+ t.Fatal(err)
+ }
+ reader := acquireSendGateAsync(&gate, context.Background(), closed,
false)
+ waitSendGateQueue(t, &gate, 1)
+ writer := acquireSendGateAsync(&gate, context.Background(), closed,
true)
+ waitSendGateQueue(t, &gate, 2)
+ close(closed)
+ receiveSendGateResult(t, reader, errMCPClientSessionClosed)
+ receiveSendGateResult(t, writer, errMCPClientSessionClosed)
+ waitSendGateQueue(t, &gate, 0)
+ gate.release(true)
+ if err := gate.acquire(context.Background(), closed, false);
!errors.Is(err, errMCPClientSessionClosed) {
+ t.Fatalf("post-close acquire = %v", err)
+ }
+}
+
+func TestSendGatePreCancelledContextNeverAcquires(t *testing.T) {
+ var gate sendGate
+ closed := make(chan struct{})
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ for _, exclusive := range []bool{false, true} {
+ if err := gate.acquire(ctx, closed, exclusive); !errors.Is(err,
context.Canceled) {
+ t.Fatalf("acquire = %v", err)
+ }
+ }
+ if gate.readers != 0 || gate.writer || gate.waiters.Len() != 0 {
+ t.Fatal("cancelled acquisition changed gate state")
+ }
+}