- #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(窗口极窄),确定性论证建立在「可控对抗条件复现 + 根因」上。
404 lines
13 KiB
Go
404 lines
13 KiB
Go
package vision
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"image"
|
||
"image/color"
|
||
"image/png"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"net/url"
|
||
"strings"
|
||
"sync"
|
||
"sync/atomic"
|
||
"testing"
|
||
"time"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||
)
|
||
|
||
// pngBytesAlt 构造一张与 pngBytes 字节不同的合法 PNG,用于「同一 URL
|
||
// 内容变了」的用例。
|
||
func pngBytesAlt(t *testing.T) []byte {
|
||
t.Helper()
|
||
img := image.NewRGBA(image.Rect(0, 0, 1, 1))
|
||
img.Set(0, 0, color.RGBA{R: 0xff, G: 0xee, B: 0xdd, A: 0xff})
|
||
var buf bytes.Buffer
|
||
if err := png.Encode(&buf, img); err != nil {
|
||
t.Fatalf("encode alt png: %v", err)
|
||
}
|
||
return buf.Bytes()
|
||
}
|
||
|
||
// newMainModelResolver 构造一个把图像路由到主模型的解析器(主模型声明
|
||
// 支持视觉),工具路径被调用即视为测试失败。
|
||
func newMainModelResolver() *MessageResolver {
|
||
return NewMessageResolver(NewRouter(WithVisionTool("vision")), func() ModelInfo {
|
||
return ModelInfo{Name: "vision-model", SupportsVision: true}
|
||
}, func(_ context.Context, _ string, _ map[string]any) (any, error) {
|
||
return nil, ErrNotConfigured
|
||
})
|
||
}
|
||
|
||
// resolveMainImage 走一遍解析器的主模型路径,返回内联后的图像串。
|
||
func resolveMainImage(res *MessageResolver, src string) (string, error) {
|
||
msg, err := res.Resolve(context.Background(), model.ImageMessage(model.RoleUser, "x", src))
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
for _, p := range msg.Content {
|
||
if p.Type == model.ContentTypeImage {
|
||
return p.ImageURL, nil
|
||
}
|
||
}
|
||
return "", errors.New("解析结果里没有图像部件")
|
||
}
|
||
|
||
// TestImageCache_SecondResolveFetchesOnce 是本缺陷的主用例:同一个 URL
|
||
// 连续解析两次(两个回合),上游只被取回一次,两次结果逐字节相同。
|
||
func TestImageCache_SecondResolveFetchesOnce(t *testing.T) {
|
||
t.Parallel()
|
||
png := pngBytes(t)
|
||
var hits atomic.Int32
|
||
imgSrv := newImageServer(t, png, "image/png", &hits)
|
||
res := newMainModelResolver()
|
||
src := imgSrv.URL + "/cat.png"
|
||
|
||
first, err := resolveMainImage(res, src)
|
||
if err != nil {
|
||
t.Fatalf("第一次解析: %v", err)
|
||
}
|
||
second, err := resolveMainImage(res, src)
|
||
if err != nil {
|
||
t.Fatalf("第二次解析: %v", err)
|
||
}
|
||
want := DataURI("image/png", png)
|
||
if first != want || second != want {
|
||
t.Fatalf("内联结果 = (%q, %q),want %q", first, second, want)
|
||
}
|
||
if got := hits.Load(); got != 1 {
|
||
t.Fatalf("上游被取回 %d 次,want 1(第二个回合必须命中缓存)", got)
|
||
}
|
||
if sources, _, _ := res.cache.stats(); sources != 1 {
|
||
t.Fatalf("缓存来源条数 = %d,want 1", sources)
|
||
}
|
||
}
|
||
|
||
// 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
|
||
payload := original
|
||
var hits atomic.Int32
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||
hits.Add(1)
|
||
mu.Lock()
|
||
body := payload
|
||
mu.Unlock()
|
||
w.Header().Set("Content-Type", "image/png")
|
||
_, _ = w.Write(body)
|
||
}))
|
||
t.Cleanup(srv.Close)
|
||
|
||
clock := newTestClock()
|
||
res := newMainModelResolver()
|
||
res.cache.ttl = testCacheTTL
|
||
res.cache.now = clock.Now
|
||
src := srv.URL + "/cat.png"
|
||
|
||
first, err := resolveMainImage(res, src)
|
||
if err != nil {
|
||
t.Fatalf("第一次解析: %v", err)
|
||
}
|
||
if first != DataURI("image/png", original) {
|
||
t.Fatalf("第一次内联结果不对: %q", first)
|
||
}
|
||
|
||
mu.Lock()
|
||
payload = changed
|
||
mu.Unlock()
|
||
|
||
// TTL 内:仍返回旧图,且没有再取回。时钟没有前进,所以这一段的结论由
|
||
// 缓存自身的判据决定,与机器快慢无关。
|
||
if got, err := resolveMainImage(res, src); err != nil || got != first {
|
||
t.Fatalf("TTL 内 = (%q, %v),want 旧缓存 %q", got, err, first)
|
||
}
|
||
if got := hits.Load(); got != 1 {
|
||
t.Fatalf("TTL 内上游被取回 %d 次,want 1", got)
|
||
}
|
||
|
||
// 把注入的时钟推进到 TTL 边界:get 用 !now.Before(expiresAt) 判定过期,
|
||
// 到达边界即为过期。取代旧写法的 time.Sleep(60ms) 墙钟等待。
|
||
clock.Advance(testCacheTTL)
|
||
|
||
third, err := resolveMainImage(res, src)
|
||
if err != nil {
|
||
t.Fatalf("TTL 到期后解析: %v", err)
|
||
}
|
||
wantChanged := DataURI("image/png", changed)
|
||
if third != wantChanged {
|
||
t.Fatalf("TTL 到期后 = %q,want 新内容 %q(旧映射没有失效)", third, wantChanged)
|
||
}
|
||
if got := hits.Load(); got != 2 {
|
||
t.Fatalf("上游被取回 %d 次,want 2(TTL 到期必须重取)", got)
|
||
}
|
||
|
||
// 刷新后的映射必须指向新内容本身:若回填只换了来源映射、内容条目仍是
|
||
// 旧 URI,下一次命中就会把旧图重新喂给模型。
|
||
if got, err := resolveMainImage(res, src); err != nil || got != wantChanged {
|
||
t.Fatalf("刷新后的缓存命中 = (%q, %v),want %q(回填没有更新内容)", got, err, wantChanged)
|
||
}
|
||
if got := hits.Load(); got != 2 {
|
||
t.Fatalf("刷新后的命中又取回了上游(%d 次),want 2", got)
|
||
}
|
||
}
|
||
|
||
// TestImageCache_FailureIsNotCachedAndDiagnosable 覆盖失败路径:取回失败
|
||
// 不写缓存、第二次仍会重试,错误是结构化的 SourceFailure —— 只带来源主机
|
||
// 与原因,不带 URL 路径、查询串与凭据,且宿主能按结构断言取到主机(loop
|
||
// 的 WARN 因此可诊断)。
|
||
func TestImageCache_FailureIsNotCachedAndDiagnosable(t *testing.T) {
|
||
t.Parallel()
|
||
png := pngBytes(t)
|
||
var hits atomic.Int32
|
||
var failing atomic.Bool
|
||
failing.Store(true)
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||
hits.Add(1)
|
||
if failing.Load() {
|
||
w.WriteHeader(http.StatusInternalServerError)
|
||
return
|
||
}
|
||
w.Header().Set("Content-Type", "image/png")
|
||
_, _ = w.Write(png)
|
||
}))
|
||
t.Cleanup(srv.Close)
|
||
|
||
wantHost, err := url.Parse(srv.URL)
|
||
if err != nil {
|
||
t.Fatalf("parse test server url: %v", err)
|
||
}
|
||
res := newMainModelResolver()
|
||
// 带查询串的地址:错误文案里绝不能出现凭据或路径。
|
||
src := srv.URL + "/cat.png?token=SEKRET"
|
||
|
||
_, err = resolveMainImage(res, src)
|
||
if !errors.Is(err, ErrFetch) {
|
||
t.Fatalf("第一次错误 = %v,want ErrFetch", err)
|
||
}
|
||
var sf *SourceFailure
|
||
if !errors.As(err, &sf) {
|
||
t.Fatalf("错误不是 *SourceFailure,调用方拿不到来源主机: %v", err)
|
||
}
|
||
if sf.Host != wantHost.Host {
|
||
t.Fatalf("Host = %q,want %q", sf.Host, wantHost.Host)
|
||
}
|
||
var withHost interface{ ImageSourceHost() string }
|
||
if !errors.As(err, &withHost) || withHost.ImageSourceHost() != wantHost.Host {
|
||
t.Fatalf("错误未按结构断言暴露来源主机(loop 的 WARN 会丢 host): %v", err)
|
||
}
|
||
msg := err.Error()
|
||
for _, leak := range []string{"SEKRET", "token", "cat.png"} {
|
||
if strings.Contains(msg, leak) {
|
||
t.Fatalf("错误文案泄漏了 URL(%q): %v", leak, err)
|
||
}
|
||
}
|
||
if !strings.Contains(msg, wantHost.Host) {
|
||
t.Fatalf("错误文案没有来源主机: %v", err)
|
||
}
|
||
if sources, contents, bytes := res.cache.stats(); sources != 0 || contents != 0 || bytes != 0 {
|
||
t.Fatalf("失败结果被写进了缓存: sources=%d contents=%d bytes=%d", sources, contents, bytes)
|
||
}
|
||
|
||
failing.Store(false)
|
||
got, err := resolveMainImage(res, src)
|
||
if err != nil {
|
||
t.Fatalf("第二次解析(来源已恢复): %v", err)
|
||
}
|
||
if want := DataURI("image/png", png); got != want {
|
||
t.Fatalf("第二次解析 = %q,want %q", got, want)
|
||
}
|
||
if n := hits.Load(); n != 2 {
|
||
t.Fatalf("上游被取回 %d 次,want 2(失败必须可重试)", n)
|
||
}
|
||
}
|
||
|
||
// TestImageCache_NotImageIsNotCached 是既有断言(非图像 → ErrNotImage)
|
||
// 在缓存路径上的延伸:HTML 错误页报错,且不会被缓存住。
|
||
func TestImageCache_NotImageIsNotCached(t *testing.T) {
|
||
t.Parallel()
|
||
var hits atomic.Int32
|
||
imgSrv := newImageServer(t, htmlBytes(), "image/png", &hits)
|
||
res := newMainModelResolver()
|
||
src := imgSrv.URL + "/err"
|
||
|
||
for attempt := 1; attempt <= 2; attempt++ {
|
||
_, err := resolveMainImage(res, src)
|
||
if !errors.Is(err, ErrNotImage) {
|
||
t.Fatalf("第 %d 次错误 = %v,want ErrNotImage", attempt, err)
|
||
}
|
||
}
|
||
if got := hits.Load(); got != 2 {
|
||
t.Fatalf("上游被取回 %d 次,want 2(非图像不得进缓存)", got)
|
||
}
|
||
if sources, contents, _ := res.cache.stats(); sources != 0 || contents != 0 {
|
||
t.Fatalf("非图像被写进了缓存: sources=%d contents=%d", sources, contents)
|
||
}
|
||
}
|
||
|
||
// TestImageCache_BoundsAreEnforced 钉住边界:来源条数与内容总字节都不会
|
||
// 无界增长,最老的条目按 LRU 淘汰(淘汰后不再命中)。
|
||
func TestImageCache_BoundsAreEnforced(t *testing.T) {
|
||
t.Parallel()
|
||
c := newImageCache()
|
||
c.maxSrc = 2
|
||
c.maxCtn = 1000 // 条数上限放到很大,这里只让字节上限与来源上限起作用
|
||
uri := strings.Repeat("A", 200)
|
||
c.maxBytes = 3 * len(uri)
|
||
|
||
for i := 0; i < 50; i++ {
|
||
c.put(fmt.Sprintf("https://example.com/%d.png", i), "image/png", []byte{byte(i)}, uri)
|
||
}
|
||
sources, contents, bytes := c.stats()
|
||
if sources > c.maxSrc {
|
||
t.Fatalf("来源条数 = %d,超过上限 %d", sources, c.maxSrc)
|
||
}
|
||
if bytes > c.maxBytes {
|
||
t.Fatalf("缓存字节 = %d,超过上限 %d", bytes, c.maxBytes)
|
||
}
|
||
if contents > c.maxCtn {
|
||
t.Fatalf("内容条目数 = %d,超过条数上限 %d", contents, c.maxCtn)
|
||
}
|
||
if maxByBytes := c.maxBytes / len(uri); contents > maxByBytes {
|
||
t.Fatalf("内容条目数 = %d,超过按字节上限换算的 %d", contents, maxByBytes)
|
||
}
|
||
if _, ok := c.get("https://example.com/0.png"); ok {
|
||
t.Fatal("最老的来源仍在缓存里(LRU 淘汰没有生效)")
|
||
}
|
||
if _, ok := c.get("https://example.com/49.png"); !ok {
|
||
t.Fatal("最新的来源不在缓存里")
|
||
}
|
||
}
|
||
|
||
// TestImageCache_ContentCountCapEnforced 单独钉住内容条目数上限:字节上限
|
||
// 放得很大时,只有条数上限起作用 —— 极小的图片堆不出极多的条目,字节数
|
||
// 也随之收敛,最老的内容被 LRU 淘汰。
|
||
func TestImageCache_ContentCountCapEnforced(t *testing.T) {
|
||
t.Parallel()
|
||
c := newImageCache()
|
||
c.maxSrc = 1000
|
||
c.maxCtn = 4
|
||
c.maxBytes = 1 << 30
|
||
uri := strings.Repeat("A", 200)
|
||
|
||
for i := 0; i < 50; i++ {
|
||
c.put(fmt.Sprintf("https://example.com/%d.png", i), "image/png", []byte{byte(i)}, uri)
|
||
}
|
||
sources, contents, bytes := c.stats()
|
||
if contents > c.maxCtn {
|
||
t.Fatalf("内容条目数 = %d,超过条数上限 %d", contents, c.maxCtn)
|
||
}
|
||
if want := c.maxCtn * len(uri); bytes != want {
|
||
t.Fatalf("缓存字节 = %d,want %d(字节数应随条数上限收敛)", bytes, want)
|
||
}
|
||
if sources > c.maxSrc {
|
||
t.Fatalf("来源条数 = %d,超过上限 %d", sources, c.maxSrc)
|
||
}
|
||
if _, ok := c.get("https://example.com/0.png"); ok {
|
||
t.Fatal("最老的内容仍在缓存里(LRU 淘汰没有生效)")
|
||
}
|
||
if _, ok := c.get("https://example.com/49.png"); !ok {
|
||
t.Fatal("最新的内容不在缓存里")
|
||
}
|
||
}
|
||
|
||
// TestImageCache_ConcurrentResolveIsSafe 覆盖并发解析同一 URL:结构状态
|
||
// 不被破坏(配合 -race),结果一致;并发风暴之后的解析必定命中缓存。
|
||
func TestImageCache_ConcurrentResolveIsSafe(t *testing.T) {
|
||
t.Parallel()
|
||
png := pngBytes(t)
|
||
var hits atomic.Int32
|
||
imgSrv := newImageServer(t, png, "image/png", &hits)
|
||
res := newMainModelResolver()
|
||
src := imgSrv.URL + "/cat.png"
|
||
want := DataURI("image/png", png)
|
||
|
||
const workers = 8
|
||
results := make([]string, workers)
|
||
errs := make([]error, workers)
|
||
var wg sync.WaitGroup
|
||
for i := 0; i < workers; i++ {
|
||
wg.Add(1)
|
||
go func(i int) {
|
||
defer wg.Done()
|
||
results[i], errs[i] = resolveMainImage(res, src)
|
||
}(i)
|
||
}
|
||
wg.Wait()
|
||
|
||
for i := 0; i < workers; i++ {
|
||
if errs[i] != nil {
|
||
t.Fatalf("并发解析 %d: %v", i, errs[i])
|
||
}
|
||
if results[i] != want {
|
||
t.Fatalf("并发解析 %d = %q,want %q", i, results[i], want)
|
||
}
|
||
}
|
||
if got := hits.Load(); got == 0 || got > workers {
|
||
t.Fatalf("上游被取回 %d 次,want 1..%d", got, workers)
|
||
}
|
||
|
||
before := hits.Load()
|
||
if got, err := resolveMainImage(res, src); err != nil || got != want {
|
||
t.Fatalf("并发风暴后 = (%q, %v),want %q", got, err, want)
|
||
}
|
||
if after := hits.Load(); after != before {
|
||
t.Fatalf("并发风暴后的解析仍取回了上游(%d → %d),缓存没生效", before, after)
|
||
}
|
||
sources, _, _ := res.cache.stats()
|
||
if sources != 1 {
|
||
t.Fatalf("缓存来源条数 = %d,want 1(同一 URL 只应有一条映射)", sources)
|
||
}
|
||
}
|