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

404 lines
13 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 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)
}
}