- #6 capability/mcp TestRegistry_ToolListChangedRefreshes:真因**不是慢,而是条件竞态** —— 本机协商到协议 2026-07-28(SEP-2575)时服务器**只**把 notifications/tools/list_changed 投给已登记 subscriptions/listen 的会话,而客户端订阅是 fire-and-forget(Connect 返回 ≠ 服务器已登记),服务器又在变更后 10ms 发一次、丢了不补 ⇒ 订阅登记晚于那个定时器,该次变更**永远**不生效(旧 waitFor(5s) 只能超时)。修法:显式门控 —— 先等服务器 notifications/subscriptions/acknowledged(服务器先登记再回确认)→ 再触发变更 → 再断言刷新由 tools/list_changed 驱动(既有断言一条未少、另加一条更强的驱动断言)。协议回落旧版时门控自动跳过。 - #7 multimodal/vision TestImageCache_RefetchesAfterTTLWhenContentChanges:真因是把**墙钟当判据**(ttl=20ms 下「TTL 内」成立与否只由调度抖动决定,实测空载≈0、并行负载下 >20ms 会同时打红两条断言)。修法:注入可手动推进的 testClock(生产本就有 cache.now 字段、默认 time.Now,零生产改动),断言一条未少。 - 附带:修掉 G5 批次遗留的第三条墙钟兜底 —— gateway/voice_turn_test.go 的 waitFrame 3s → 15s。**放宽的是兜底期限、不是断言**:它断言的是「帧确实到达」,帧没到照样 Fatal;实测单跑 -count=20 全绿、只在整树并行下偶红(语音回合要过编排器 → 门控假 runner → 假 TTS → 定向回连接)。 - 主会话独立验收:隔离 worktree(F2 三个测试文件)build/vet 绿、两条目标用例各 -count=20 绿;加本条兜底修复后整树连跑 2 次均 59 ok / 0 FAIL(修前那次整树抓到 voice 那条红 → 归因见上)。 - 子代理自报(已核实并清理):它在加压时用 30 个 yes 做负载、第一次 kill 没杀干净 ⇒ 整机 12 核被占满约 15 分钟(期间所有 go 命令近乎停滞,也解释了本轮测试为何异常慢);已全部终止(Get-Process yes = 0),此后证据均在干净环境重跑。它同时**如实说明**:在纯加压下没能直接撞出这两条原始 flake(窗口极窄),确定性论证建立在「可控对抗条件复现 + 根因」上。
145 lines
4.3 KiB
Go
145 lines
4.3 KiB
Go
package mcp
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
sdkjsonrpc "github.com/modelcontextprotocol/go-sdk/jsonrpc"
|
|
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
|
|
)
|
|
|
|
// TestClient_KeepAlive_SendsPings keepalive 接线:配了间隔后 ping 真的上了线路。
|
|
// ping 本身由 SDK 负责,这里只验我们把配置传导到了 SDK 会话上。
|
|
func TestClient_KeepAlive_SendsPings(t *testing.T) {
|
|
t.Parallel()
|
|
sn := &sniffTransport{inner: startFakeServer(t, newFakeServer())}
|
|
c := New(MCPConfig{Name: "fake"},
|
|
WithTransport(sn),
|
|
// 固定到 keepalive 生效的旧协议版本,避免新版协议的订阅路径干扰。
|
|
WithProtocolVersion("2025-11-25"),
|
|
WithKeepAlive(20*time.Millisecond, 1))
|
|
if err := c.Connect(context.Background()); err != nil {
|
|
t.Fatalf("Connect: %v", err)
|
|
}
|
|
defer c.Close()
|
|
if got := c.ServerInfo().ProtocolVersion; got != "2025-11-25" {
|
|
t.Fatalf("协议版本 = %q, 期望 2025-11-25", got)
|
|
}
|
|
waitFor(t, 5*time.Second, "线路上出现 keepalive ping", func() bool { return sn.called("ping") })
|
|
}
|
|
|
|
// TestClient_NoKeepAlive_NoPings 不配 keepalive 时不发 ping(反证:ping 不是 SDK 默认行为)。
|
|
func TestClient_NoKeepAlive_NoPings(t *testing.T) {
|
|
t.Parallel()
|
|
sn := &sniffTransport{inner: startFakeServer(t, newFakeServer())}
|
|
c := New(MCPConfig{Name: "fake"}, WithTransport(sn), WithProtocolVersion("2025-11-25"))
|
|
if err := c.Connect(context.Background()); err != nil {
|
|
t.Fatalf("Connect: %v", err)
|
|
}
|
|
defer c.Close()
|
|
if _, err := c.DiscoverTools(context.Background()); err != nil {
|
|
t.Fatalf("DiscoverTools: %v", err)
|
|
}
|
|
time.Sleep(150 * time.Millisecond)
|
|
if sn.called("ping") {
|
|
t.Fatal("没配 keepalive 却发了 ping")
|
|
}
|
|
if !sn.called("tools/list") {
|
|
t.Fatalf("线路上没有 tools/list, 已见方法 = %v", sn.methods())
|
|
}
|
|
}
|
|
|
|
// sniffTransport 包一层 SDK 传输,在线路层记录两个方向的 JSON-RPC 请求方法:
|
|
// seen 是客户端发出的(断言客户端确实发了某个请求),fromServer 是服务器发来的
|
|
// (断言服务器确实回了某条通知,例如 SEP-2575 的订阅确认)。
|
|
type sniffTransport struct {
|
|
inner mcpsdk.Transport
|
|
mu sync.Mutex
|
|
seen []string
|
|
fromServer []string
|
|
}
|
|
|
|
// Connect 实现 mcpsdk.Transport。
|
|
func (t *sniffTransport) Connect(ctx context.Context) (mcpsdk.Connection, error) {
|
|
conn, err := t.inner.Connect(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &sniffConn{Connection: conn, owner: t}, nil
|
|
}
|
|
|
|
// called 报告某个方法是否出现过。
|
|
func (t *sniffTransport) called(method string) bool {
|
|
t.mu.Lock()
|
|
defer t.mu.Unlock()
|
|
for _, m := range t.seen {
|
|
if m == method {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// methods 返回已见方法快照。
|
|
func (t *sniffTransport) methods() []string {
|
|
t.mu.Lock()
|
|
defer t.mu.Unlock()
|
|
return append([]string(nil), t.seen...)
|
|
}
|
|
|
|
// record 记录一个发出的方法名。
|
|
func (t *sniffTransport) record(method string) {
|
|
t.mu.Lock()
|
|
defer t.mu.Unlock()
|
|
t.seen = append(t.seen, method)
|
|
}
|
|
|
|
// received 报告服务器是否发来过某个方法。
|
|
func (t *sniffTransport) received(method string) bool {
|
|
t.mu.Lock()
|
|
defer t.mu.Unlock()
|
|
for _, m := range t.fromServer {
|
|
if m == method {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// recordReceived 记录一个服务器发来的方法名。
|
|
func (t *sniffTransport) recordReceived(method string) {
|
|
t.mu.Lock()
|
|
defer t.mu.Unlock()
|
|
t.fromServer = append(t.fromServer, method)
|
|
}
|
|
|
|
// sniffConn 拦截写入的请求消息,记录方法名后交给内层连接。
|
|
type sniffConn struct {
|
|
mcpsdk.Connection
|
|
owner *sniffTransport
|
|
}
|
|
|
|
// Write 记录请求方法后转发。
|
|
func (c *sniffConn) Write(ctx context.Context, msg sdkjsonrpc.Message) error {
|
|
if req, ok := msg.(*sdkjsonrpc.Request); ok {
|
|
c.owner.record(req.Method)
|
|
}
|
|
return c.Connection.Write(ctx, msg)
|
|
}
|
|
|
|
// Read 记录服务器发来的请求方法后转发。通知也是不带 ID 的请求帧,因此
|
|
// 订阅确认、tools/list_changed 这类服务器通知都在这里现形。
|
|
func (c *sniffConn) Read(ctx context.Context) (sdkjsonrpc.Message, error) {
|
|
msg, err := c.Connection.Read(ctx)
|
|
if err != nil {
|
|
// 出错时内层消息未被上层使用(jsonrpc2 读循环遇错即退出),原样透传错误。
|
|
return nil, err
|
|
}
|
|
if req, ok := msg.(*sdkjsonrpc.Request); ok {
|
|
c.owner.recordReceived(req.Method)
|
|
}
|
|
return msg, nil
|
|
}
|