Files
AskaEth 6066165394
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
test(flake): 清掉最后三条墙钟/条件竞态(mcp 订阅门控 + vision 注入时钟 + voice 兜底期限)
- #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(窗口极窄),确定性论证建立在「可控对抗条件复现 + 根因」上。
2026-10-04 21:11:17 +08:00

325 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package mcp
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"os"
"slices"
"sync/atomic"
"testing"
"time"
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// TestClient_DiscoverTools_InMemory 走真实 SDK 内存传输打通「连接 → 工具自动发现」。
func TestClient_DiscoverTools_InMemory(t *testing.T) {
t.Parallel()
c := connectFakeClient(t, newFakeServer())
info := c.ServerInfo()
if info.Name != "fake-mcp" || info.Version != "1.0.0" {
t.Fatalf("ServerInfo = %+v, 期望 fake-mcp/1.0.0", info)
}
if info.ProtocolVersion == "" {
t.Fatal("ProtocolVersion 为空,协商结果没有保留")
}
if _, ok := info.Capabilities["tools"]; !ok {
t.Fatalf("服务器声明的 capabilities 没有保留 tools 字段:%v", info.Capabilities)
}
if !info.Features.Tools {
t.Fatalf("Features.Tools = false,服务器已声明 tools:%v", info.Capabilities)
}
tools, err := c.ListTools(context.Background())
if err != nil {
t.Fatalf("ListTools: %v", err)
}
got := toolNames(tools)
slices.Sort(got)
if !slices.Equal(got, []string{"add", "echo"}) {
t.Fatalf("tools = %v, 期望 [add echo]", got)
}
discovered, err := c.DiscoverTools(context.Background())
if err != nil {
t.Fatalf("DiscoverTools: %v", err)
}
if len(discovered) != 2 {
t.Fatalf("DiscoverTools 返回 %d 把工具,期望 2", len(discovered))
}
if c.SchemaCache().Len() != 2 {
t.Fatalf("schema 缓存条目 = %d,期望 2(发现即缓存)", c.SchemaCache().Len())
}
}
// TestClient_DiscoverTools_StdioProcess 用真实子进程(stdio 打桩)证明 stdio 传输下
// 工具自动发现能跑通:子进程就是本测试二进制,以 MCP server 身份讲协议。
func TestClient_DiscoverTools_StdioProcess(t *testing.T) {
t.Parallel()
self, err := os.Executable()
if err != nil {
t.Fatalf("os.Executable: %v", err)
}
c := New(MCPConfig{
Name: "stdio-fake",
Command: self,
Args: []string{"-test.run=^TestMCPStdioHelper$"},
Env: map[string]string{stdioHelperEnv: "1"},
})
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := c.Connect(ctx); err != nil {
t.Fatalf("Connect: %v", err)
}
defer c.Close()
tools, err := c.DiscoverTools(ctx)
if err != nil {
t.Fatalf("DiscoverTools: %v", err)
}
got := toolNames(toToolInfos(tools))
slices.Sort(got)
if !slices.Equal(got, []string{"add", "echo"}) {
t.Fatalf("stdio 发现的工具 = %v, 期望 [add echo]", got)
}
res, err := c.CallTool(ctx, "echo", map[string]any{"name": "stdio"})
if err != nil {
t.Fatalf("CallTool: %v", err)
}
if text := contentText(res.Content); text != "hello stdio" {
t.Fatalf("工具结果 = %q, 期望 hello stdio", text)
}
}
// TestClient_DiscoverTools_StreamableHTTP 用 httptest 打桩 Streamable HTTP 端点,
// 验证 URL 型传输 + bearer 鉴权 + 工具自动发现整条链。
func TestClient_DiscoverTools_StreamableHTTP(t *testing.T) {
t.Parallel()
srv := newFakeServer()
handler := mcpsdk.NewStreamableHTTPHandler(func(*http.Request) *mcpsdk.Server { return srv }, nil)
var authorized atomic.Bool
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer sk-test" {
w.WriteHeader(http.StatusUnauthorized)
return
}
authorized.Store(true)
handler.ServeHTTP(w, r)
}))
defer ts.Close()
c := New(MCPConfig{Name: "http-fake", URL: ts.URL, TransportType: "http", APIKey: "sk-test"})
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := c.Connect(ctx); err != nil {
t.Fatalf("Connect: %v", err)
}
defer c.Close()
tools, err := c.DiscoverTools(ctx)
if err != nil {
t.Fatalf("DiscoverTools: %v", err)
}
if len(tools) != 2 {
t.Fatalf("Streamable HTTP 发现 %d 把工具,期望 2", len(tools))
}
if !authorized.Load() {
t.Fatal("服务器没有收到带 bearer 凭据的请求")
}
res, err := c.CallTool(ctx, "add", map[string]any{"a": 1, "b": 2})
if err != nil {
t.Fatalf("CallTool: %v", err)
}
if text := contentText(res.Content); text != "3" {
t.Fatalf("add 结果 = %q, 期望 3", text)
}
}
// TestClient_ListTools_RefusesUndeclaredCapability 证明「未声明不得调用」:
// 服务器只声明 logging,客户端拒绝发 tools/list。
func TestClient_ListTools_RefusesUndeclaredCapability(t *testing.T) {
t.Parallel()
c := connectFakeClient(t, newEmptyServer())
if c.Features().Tools {
t.Fatalf("空服务器不应推断出 tools 能力:%v", c.ServerInfo().Capabilities)
}
_, err := c.ListTools(context.Background())
if !errors.Is(err, ErrCapabilityNotDeclared) {
t.Fatalf("ListTools error = %v, 期望 ErrCapabilityNotDeclared", err)
}
if _, err := c.DiscoverTools(context.Background()); !errors.Is(err, ErrCapabilityNotDeclared) {
t.Fatalf("DiscoverTools error = %v, 期望 ErrCapabilityNotDeclared", err)
}
}
// TestRegistry_Register_AutoRegisters 打通「自动发现 → 注册进 capability registry
// → 模型可调用」这条链(用 registry 里存的工具值真的发起一次调用)。
func TestRegistry_Register_AutoRegisters(t *testing.T) {
t.Parallel()
reg := registry.New()
tr := startFakeServer(t, newFakeServer())
r := NewRegistry(reg, MCPConfig{Name: "calc"}, WithTransport(tr))
if err := r.Register(context.Background()); err != nil {
t.Fatalf("Register: %v", err)
}
names := r.ToolNames()
slices.Sort(names)
if !slices.Equal(names, []string{"add", "echo"}) {
t.Fatalf("ToolNames = %v, 期望 [add echo]", names)
}
cap, err := reg.Get("echo")
if err != nil {
t.Fatalf("registry.Get(echo): %v", err)
}
if cap.Kind != registry.KindTool {
t.Fatalf("echo 的 Kind = %q, 期望 %q", cap.Kind, registry.KindTool)
}
tool, ok := cap.Value.(sdk.Tool)
if !ok {
t.Fatalf("echo 的 Value 类型 = %T, 期望 sdk.Tool", cap.Value)
}
// 不给参数调用:默认值 world 由 input schema 补上(证明 schema 校验/默认值生效)。
out, err := tool.Execute(context.Background(), nil)
if err != nil {
t.Fatalf("Execute(echo): %v", err)
}
if out != "hello world" {
t.Fatalf("echo 结果 = %v, 期望 hello world(默认值未生效?)", out)
}
// 必填参数缺失或类型错误时必须被 input schema 拦下,不把坏参数发给服务器。
addCap, err := reg.Get("add")
if err != nil {
t.Fatalf("registry.Get(add): %v", err)
}
addTool := addCap.Value.(sdk.Tool)
if _, err := addTool.Execute(context.Background(), map[string]any{"a": "not-a-number"}); err == nil {
t.Fatal("非法参数没有被 input schema 拦下")
}
}
// TestRegistry_Register_PropagatesConnectError 连接失败要冒泡,不能静默注册空工具集。
func TestRegistry_Register_PropagatesConnectError(t *testing.T) {
t.Parallel()
reg := registry.New()
r := NewRegistry(reg, MCPConfig{Name: "broken", Command: "definitely-not-a-real-binary-xyz"})
if err := r.Register(context.Background()); err == nil {
t.Fatal("Register 成功,期望连接错误")
}
}
// TestRegistry_Register_NilRegistry 空 registry 必须报错。
func TestRegistry_Register_NilRegistry(t *testing.T) {
t.Parallel()
r := NewRegistry(nil, MCPConfig{Name: "calc"})
if err := r.Register(context.Background()); err == nil {
t.Fatal("Register 成功,期望 nil registry 错误")
}
}
// TestRegistry_Unregister_RemovesTools 卸载要真从 registry 移除并关会话,且幂等。
func TestRegistry_Unregister_RemovesTools(t *testing.T) {
t.Parallel()
reg := registry.New()
tr := startFakeServer(t, newFakeServer())
r := NewRegistry(reg, MCPConfig{Name: "calc"}, WithTransport(tr))
if err := r.Register(context.Background()); err != nil {
t.Fatalf("Register: %v", err)
}
if err := r.Unregister(context.Background()); err != nil {
t.Fatalf("Unregister: %v", err)
}
if err := r.Unregister(context.Background()); err != nil {
t.Fatalf("第二次 Unregister: %v", err)
}
for _, name := range []string{"add", "echo"} {
if _, err := reg.Get(name); !errors.Is(err, registry.ErrNotFound) {
t.Fatalf("Get(%q) 卸载后 = %v, 期望 ErrNotFound", name, err)
}
}
}
const (
// methodSubscriptionsAck 是 SEP-2575 的订阅确认通知方法名(SDK 未导出该常量)。
methodSubscriptionsAck = "notifications/subscriptions/acknowledged"
// methodToolListChanged 是工具清单变更通知方法名(SDK 未导出该常量)。
methodToolListChanged = "notifications/tools/list_changed"
// sep2575ProtocolVersion 是引入 subscriptions/listen 订阅流的协议版本:自它起
// 服务器只向**已登记订阅**的会话投递 list_changed 通知(更早的协议版本对全部
// 会话广播,不存在下面这个窗口)。
sep2575ProtocolVersion = "2026-07-28"
// notificationWaitBudget 是等上述事件的兜底时限:只用于避免测试挂死,
// 事件本身才是断言对象。
notificationWaitBudget = 5 * time.Second
)
// TestRegistry_ToolListChangedRefreshes 服务器运行中加工具 → 通知 → registry 增量刷新,
// 同时 schema 缓存按服务器整表失效(design/09 §1.1)。
//
// 确定性要点:现代协议(≥ sep2575ProtocolVersion)下服务器**只**把 tools/list_changed
// 投给已登记 subscriptions/listen 订阅的会话,而客户端发订阅是 fire-and-forget
// (Connect 返回 ≠ 服务器已登记),通知本身又是「变更后 10ms 发一次、丢了不补」——
// 订阅登记之前触发的变更会被永久静默丢弃。旧写法(改工具 → 轮询 5s)等于拿这个竞态
// 与机器负载对赌,输的那次等多久都等不到 mul。现在先等服务器回订阅确认(服务器先登记
// 订阅、再回确认)再触发变更,把「通知会不会来」变回确定的事。
func TestRegistry_ToolListChangedRefreshes(t *testing.T) {
t.Parallel()
srv := newFakeServer()
reg := registry.New()
sn := &sniffTransport{inner: startFakeServer(t, srv)}
r := NewRegistry(reg, MCPConfig{Name: "calc"}, WithTransport(sn))
if err := r.Register(context.Background()); err != nil {
t.Fatalf("Register: %v", err)
}
if v := r.mcp.ServerInfo().ProtocolVersion; v >= sep2575ProtocolVersion {
waitFor(t, notificationWaitBudget, "服务器回订阅确认 "+methodSubscriptionsAck,
func() bool { return sn.received(methodSubscriptionsAck) })
}
_, _ = reg.Get("echo") // 让缓存先有命中记录,便于确认失效计数
srv.AddTool(&mcpsdk.Tool{
Name: "mul",
Description: "两数相乘",
InputSchema: addToolSpec().InputSchema,
}, addHandler)
waitFor(t, notificationWaitBudget, "registry 出现新工具 mul", func() bool {
_, err := reg.Get("mul")
return err == nil
})
// 新工具必须由变更通知驱动刷进来,而不是别的路径顺手带进来的。
if !sn.received(methodToolListChanged) {
t.Fatalf("registry 出现了 mul,但客户端没收到 %s", methodToolListChanged)
}
if _, err := reg.Get("echo"); err != nil {
t.Fatalf("刷新后 echo 丢失:%v", err)
}
if got := r.ToolNames(); !slices.Contains(got, "mul") {
t.Fatalf("ToolNames = %v, 期望含 mul", got)
}
}
// toToolInfos 把 sdk.Tool 列表还原成 ToolInfo(仅测试断言工具名用)。
func toToolInfos(tools []sdk.Tool) []ToolInfo {
out := make([]ToolInfo, 0, len(tools))
for _, t := range tools {
info := ToolInfo{Name: t.Name(), Description: t.Description()}
if t2, ok := t.(*mcpTool); ok {
info = t2.info
}
out = append(out, info)
}
return out
}