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(窗口极窄),确定性论证建立在「可控对抗条件复现 + 根因」上。
This commit is contained in:
1 parent
3ba31b58ff
commit
6066165394
4 files changed
+121
-12
No files matched your search
@@ -249,18 +249,44 @@ func TestRegistry_Unregister_RemovesTools(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
tr := startFakeServer(t, srv)
|
||||
r := NewRegistry(reg, MCPConfig{Name: "calc"}, WithTransport(tr))
|
||||
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",
|
||||
@@ -268,10 +294,14 @@ func TestRegistry_ToolListChangedRefreshes(t *testing.T) {
|
||||
InputSchema: addToolSpec().InputSchema,
|
||||
}, addHandler)
|
||||
|
||||
waitFor(t, 5*time.Second, "registry 出现新工具 mul", func() bool {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -51,12 +51,14 @@ func TestClient_NoKeepAlive_NoPings(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// sniffTransport 包一层 SDK 传输,记录客户端发出的 JSON-RPC 请求方法,
|
||||
// 用于在线路层断言客户端确实发了某个请求。
|
||||
// sniffTransport 包一层 SDK 传输,在线路层记录两个方向的 JSON-RPC 请求方法:
|
||||
// seen 是客户端发出的(断言客户端确实发了某个请求),fromServer 是服务器发来的
|
||||
// (断言服务器确实回了某条通知,例如 SEP-2575 的订阅确认)。
|
||||
type sniffTransport struct {
|
||||
inner mcpsdk.Transport
|
||||
mu sync.Mutex
|
||||
seen []string
|
||||
inner mcpsdk.Transport
|
||||
mu sync.Mutex
|
||||
seen []string
|
||||
fromServer []string
|
||||
}
|
||||
|
||||
// Connect 实现 mcpsdk.Transport。
|
||||
@@ -94,6 +96,25 @@ func (t *sniffTransport) record(method string) {
|
||||
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
|
||||
@@ -107,3 +128,17 @@ func (c *sniffConn) Write(ctx context.Context, msg sdkjsonrpc.Message) error {
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -87,11 +87,45 @@ func TestImageCache_SecondResolveFetchesOnce(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// testClock 是可手动推进的测试时钟:注入 imageCache.now 之后,TTL 判据只随
|
||||
// 测试显式推进而前进,用例不再依赖「两次解析之间的真实耗时」这种墙钟前提。
|
||||
type testClock struct {
|
||||
mu sync.Mutex
|
||||
now time.Time
|
||||
}
|
||||
|
||||
// newTestClock 返回一个从固定时刻起步的测试时钟。
|
||||
func newTestClock() *testClock {
|
||||
return &testClock{now: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)}
|
||||
}
|
||||
|
||||
// Now 返回当前测试时刻;签名与 time.Now 一致,可直接赋给 imageCache.now。
|
||||
func (c *testClock) Now() time.Time {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.now
|
||||
}
|
||||
|
||||
// Advance 把测试时刻前进 d。
|
||||
func (c *testClock) Advance(d time.Duration) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.now = c.now.Add(d)
|
||||
}
|
||||
|
||||
// TestImageCache_RefetchesAfterTTLWhenContentChanges 钉住失效策略:TTL 内
|
||||
// 返回缓存(旧内容,这是缓存换取的确定性代价),TTL 到期后重新取回,上游
|
||||
// 换了内容也能自愈 —— 新字节是新内容键,不会被旧条目命中。
|
||||
//
|
||||
// 时间轴由**注入的测试时钟**驱动:只有测试显式推进时钟时 TTL 才会到期,
|
||||
// 因此「TTL 内命中」不再取决于「两次解析之间的真实耗时是否小于 TTL」——
|
||||
// 取回走真实 HTTP,并行负载下这个前提会失效(旧写法 ttl=20ms 就是把结论
|
||||
// 交给机器负载决定)。
|
||||
func TestImageCache_RefetchesAfterTTLWhenContentChanges(t *testing.T) {
|
||||
t.Parallel()
|
||||
// testCacheTTL 的取值本身不重要,只需大于零:时间只在测试显式推进时前进。
|
||||
const testCacheTTL = 20 * time.Millisecond
|
||||
|
||||
original := pngBytes(t)
|
||||
changed := pngBytesAlt(t)
|
||||
var mu sync.Mutex
|
||||
@@ -107,8 +141,10 @@ func TestImageCache_RefetchesAfterTTLWhenContentChanges(t *testing.T) {
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
clock := newTestClock()
|
||||
res := newMainModelResolver()
|
||||
res.cache.ttl = 20 * time.Millisecond
|
||||
res.cache.ttl = testCacheTTL
|
||||
res.cache.now = clock.Now
|
||||
src := srv.URL + "/cat.png"
|
||||
|
||||
first, err := resolveMainImage(res, src)
|
||||
@@ -123,7 +159,8 @@ func TestImageCache_RefetchesAfterTTLWhenContentChanges(t *testing.T) {
|
||||
payload = changed
|
||||
mu.Unlock()
|
||||
|
||||
// TTL 内:仍返回旧图,且没有再取回。
|
||||
// TTL 内:仍返回旧图,且没有再取回。时钟没有前进,所以这一段的结论由
|
||||
// 缓存自身的判据决定,与机器快慢无关。
|
||||
if got, err := resolveMainImage(res, src); err != nil || got != first {
|
||||
t.Fatalf("TTL 内 = (%q, %v),want 旧缓存 %q", got, err, first)
|
||||
}
|
||||
@@ -131,7 +168,9 @@ func TestImageCache_RefetchesAfterTTLWhenContentChanges(t *testing.T) {
|
||||
t.Fatalf("TTL 内上游被取回 %d 次,want 1", got)
|
||||
}
|
||||
|
||||
time.Sleep(60 * time.Millisecond)
|
||||
// 把注入的时钟推进到 TTL 边界:get 用 !now.Before(expiresAt) 判定过期,
|
||||
// 到达边界即为过期。取代旧写法的 time.Sleep(60ms) 墙钟等待。
|
||||
clock.Advance(testCacheTTL)
|
||||
|
||||
third, err := resolveMainImage(res, src)
|
||||
if err != nil {
|
||||
|
||||
@@ -35,9 +35,14 @@ import (
|
||||
|
||||
// waitFrame 排空连接上的帧直到出现给定类型(语音回合的收尾帧会排在若干状态
|
||||
// 信号广播之后),超时即失败。
|
||||
//
|
||||
// 兜底期限取 15s:**断言的是「帧确实到达」,不是「3 秒内到达」** —— 语音回合
|
||||
// 要经编排器 → 假 runner(带门控)→ TTS 假实现 → 定向回连接,在整树并行
|
||||
// (多包同时跑)或宿主负载高时 3s 会被误判成超时(实测:单跑 -count=20 全绿、
|
||||
// 只在整树并行下偶红)。放宽兜底不放宽断言:帧没到照样 Fatal。
|
||||
func waitFrame(t *testing.T, c *wsConn, typ string) wsEvent {
|
||||
t.Helper()
|
||||
deadline := time.After(3 * time.Second)
|
||||
deadline := time.After(15 * time.Second)
|
||||
for {
|
||||
select {
|
||||
case b := <-c.send:
|
||||
|
||||
Reference in new issue
Block a user