Files
HomeAgent/internal/agent/core/queue_access_test.go
JianFeeeee f33ebee01b test(agent/core): 消除测试对 scheduler.queue 的无锁直读,并加判据防回归
## 起因

offload_test.go 等 4 个文件里有 18 处 `len(s.queue)` / `range s.queue`,
全在锁外。当时**安全**——那些测试用 newRootWithoutSchedulerLoop,
刻意不启 schedulerLoop,所以没有并发写者。

但这个安全性 ① 不由测试自身保证,只由「别改构造函数」这个口头约定保证;
② 一旦有人换成 newRootWith 就变成真竞争,症状是「今天全绿、改一次就炸」。

先确认生产代码没问题:逐处核对 scheduler.go 里 6 个读 s.queue 的函数,
全部在 mu.Lock/RLock 内(takeQueuedInputs/hasRoom/nextRef/armStop/
pendingEvents/takeAllPendingEvents)。所以这是**测试的隐患**,不是核心缺陷。

## 改动

- scheduler.go 新增两个带锁只读访问器:
  · queueLen() —— 替代 len(s.queue)
  · queueSnapshot() —— 返回浅拷贝,替代 range/索引(拷贝保证 len 与
    range 不会在遍历中途变化)
- 4 个测试文件 18 处直读全部改走访问器
- 新增 queue_access_test.go:AST 静态判据,扫所有 *_test.go,
  同一函数内出现过 Lock/RLock 即视为持锁(Go 的 defer Unlock 静态难还原,
  按函数近似足够保守);命中无锁直读即红

## 判据自检

静态判据最大的失败模式是「根本没抓到东西」,所以带双向自检:

- 正向:注入一处无锁直读 → 必须被抓到
- 反向:注入一处持锁直读 → 必须**不**被报(否则噪声大没人看)
- 真变异:把一处访问器改回 `len(s.queue)` → 红

## 验证

- go build ./... / go vet / go test ./internal/... 全绿
- go test -race ./internal/agent/core/ 无数据竞争
- 部署后:39 插件加载、3 个 multimodal 工具注册、0 panic/fatal
- graph.db integrity ok,blocks 2787 未动

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-06 09:22:12 +08:00

170 lines
5.0 KiB
Go
Raw 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 core
import (
"fmt"
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"strings"
"testing"
)
// 判据:测试代码不得在锁外直读 scheduler 的内部队列字段。
//
// ## 起因(2026-10-06)
//
// offload_test.go 曾有 6 处 `len(root.sched.queue)` / `range root.sched.queue`,
// 全在锁外。当时**安全**——那些测试用 newRootWithoutSchedulerLoop,
// 刻意不启 schedulerLoop,所以没有并发写者。但这个安全性:
//
// ① 不由测试自身保证,只由「别改构造函数」这个口头约定保证;
// ② 一旦有人把 WithoutSchedulerLoop 换成 newRootWith,就变成真竞争,
// 而症状是「今天全绿、改一次就炸」。
//
// 这正是本项目反复记录的形态:**隐患不在当前代码里,在下一次修改里。**
//
// ## 判据怎么做到不误报
//
// · 只扫 `*_test.go`(生产代码直读是另一回事,且已全部在锁内)
// · 跳过 queueLen()/queueSnapshot()(带锁访问器)
// · 同一函数内**出现过** Lock/RLock 即视为整函数持锁
// (Go 的 defer Unlock 很难静态还原,按函数近似足够保守)
//
// 判据不可达 = 判据描述的状态不存在。所以本判据自带自检:
// TestQueueAccessCriterion_SelfCheck 注入一处无锁直读,要求被抓到。
func TestNoUnlockedQueueReadInTests(t *testing.T) {
ents, err := os.ReadDir(".")
if err != nil {
t.Fatalf("读包目录: %v", err)
}
var violations []string
for _, e := range ents {
name := e.Name()
if !strings.HasSuffix(name, "_test.go") {
continue
}
violations = append(violations, unlockedQueueReadsInFile(t, filepath.Join(".", name))...)
}
if len(violations) > 0 {
t.Errorf("测试里不得在锁外直读调度器队列(请用 queueLen()/queueSnapshot()):\n\t%s",
strings.Join(violations, "\n\t"))
}
}
// unlockedQueueReadsInFile 解析一个文件并返回所有「无锁直读 .queue」的位置。
func unlockedQueueReadsInFile(t *testing.T, path string) []string {
t.Helper()
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, path, nil, 0)
if err != nil {
t.Fatalf("解析 %s: %v", path, err)
}
parent := buildParentMap(f)
// 先标记「函数体内出现过 Lock/RLock」
lockedFn := map[*ast.FuncDecl]bool{}
ast.Inspect(f, func(n ast.Node) bool {
sel, ok := n.(*ast.SelectorExpr)
if !ok || (sel.Sel.Name != "Lock" && sel.Sel.Name != "RLock") {
return true
}
if fn := enclosingFunc(parent, n); fn != nil {
lockedFn[fn] = true
}
return true
})
var out []string
ast.Inspect(f, func(n ast.Node) bool {
sel, ok := n.(*ast.SelectorExpr)
if !ok || sel.Sel.Name != "queue" {
return true
}
if fn := enclosingFunc(parent, n); fn != nil && lockedFn[fn] {
return true // 该函数整体持锁
}
pos := fset.Position(sel.Pos())
out = append(out, fmt.Sprintf("%s:%d: 直接访问 .queue(无锁)", path, pos.Line))
return true
})
return out
}
// buildParentMap 建立 AST 父指针表(ast.Inspect 的回调拿不到父节点)。
func buildParentMap(f *ast.File) map[ast.Node]ast.Node {
parent := map[ast.Node]ast.Node{}
var stack []ast.Node
ast.Inspect(f, func(n ast.Node) bool {
if n == nil {
stack = stack[:len(stack)-1]
return false
}
if len(stack) > 0 {
parent[n] = stack[len(stack)-1]
}
stack = append(stack, n)
return true
})
return parent
}
// enclosingFunc 沿父指针上溯到最近的 *ast.FuncDecl。
func enclosingFunc(parent map[ast.Node]ast.Node, n ast.Node) *ast.FuncDecl {
for cur := n; cur != nil; cur = parent[cur] {
if fn, ok := cur.(*ast.FuncDecl); ok {
return fn
}
}
return nil
}
// TestQueueAccessCriterion_SelfCheck 证明判据能红。
//
// ★ 没有自检的静态判据,无法区分「没问题」与「判据根本没抓到东西」。
//
// 本项目踩过:`-run` 正则被 shell 转义破坏,打印 "[no tests to run]" 并
// exit 0 —— 绿色 + 没测任何东西,最坏的组合。
func TestQueueAccessCriterion_SelfCheck(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "injected_test.go")
src := `package core
import "testing"
func injectedUnlockedRead(a *Agent) int {
return len(a.sched.queue)
}
var _ = injectedUnlockedRead
`
if err := os.WriteFile(path, []byte(src), 0o644); err != nil {
t.Fatal(err)
}
got := unlockedQueueReadsInFile(t, path)
if len(got) == 0 {
t.Fatal("★ 判据失效:注入的无锁直读没被抓到 —— 它对真实回归也会失效")
}
// 反向自检:持锁的写法**不得**被报(否则判据噪声太大,没人看)
path2 := filepath.Join(dir, "locked_test.go")
src2 := `package core
import "testing"
func injectedLockedRead(a *Agent) int {
a.sched.mu.Lock()
defer a.sched.mu.Unlock()
return len(a.sched.queue)
}
var _ = injectedLockedRead
`
if err := os.WriteFile(path2, []byte(src2), 0o644); err != nil {
t.Fatal(err)
}
if got := unlockedQueueReadsInFile(t, path2); len(got) != 0 {
t.Fatalf("★ 判据误报:持锁的直读被报出来 %v", got)
}
}