fix(io): 并发判据自带串话 —— 32 个 goroutine 抢 26 个 key

## 缺陷(既有,实测在本次改动之前的基线上同样复现)

`TestConsumeToolBlocksFor_ConcurrentNoCrossTalk` 约 1/8 概率假红:

    .go:105: call E 期望 [block-4-E],实际 []

根因在判据**自己**:

    const n = 32
    id := string(rune('A' + i%26))    // ← 字母只有 26 个

i=0 与 i=26 都得到 'A',i=1 与 i=27 都得 'B' …… 共 6 个 id 各被两个
goroutine 共用。两者共享同一个 key:后 Set 覆盖先 Set 的、先 Consume
拿到空或别人的值。

所以它想验证"不同 key 不串话",却让多个 goroutine 抢同一个 key ——
**判据违反了它自己想验证的前提**。这不是 `SetToolBlocksFor` /
`ConsumeToolBlocksFor` 的并发缺陷(共用 key 本就该覆盖)。

## 修法

    id := "call-" + strconv.Itoa(i)      // 唯一

实测:修改前 12 次内命中 1 次;修改后连跑 20 次 0 失败。

## 补两条判据把前提本身钉住

  TestConsumeToolBlocksFor_KeysAreIsolated
      一次性写 64 个**互不相同**的 key,逐个读回必须各得其所。
      不涉及并发 ⇒ 失败一定是真缺陷,不会偶发。

  TestConsumeToolBlocksFor_ConsumeIsOneShot
      读一次后清空(block 是一次性的)。
      变异:删掉 `delete(m.toolPendingBlocks, callID)` ⇒ 实测红:
          ★ 二次消费应得空(block 是一次性的),实际 [b]

## 为什么这条值得单独修

一个 1/8 概率假红的判据,实际效果等于**没有判据**:
它在 CI 里红了几次之后,所有人都会学会"重跑一次"。
而这正是本仓"长红 ⇒ 整道门失效"教训的一个变体。

## 验证

    go build ./...            PASS
    go vet ./...              PASS
    gofmt -l                  干净
    go test ./... -count=1    65 包全过,0 FAIL
    该判据连跑 20 次          0 失败(修改前 12 次中 1 次)
This commit is contained in:
JianFeeeee
2026-11-04 19:24:22 +08:00
parent abcb5669b8
commit 8884cf9962
2 changed files with 331 additions and 2 deletions

View File

@ -0,0 +1,272 @@
package test
// child.Registry 的契约判据:**输入 → 输出**,不碰内部字段。
//
// ## 为什么这个包必须有判据(2026-11-04)
//
// `internal/agent/core/child` 是本轮重构的核心产物(把 residents / rtChildren
// 统一成一张登记表),419 行,但 CI 覆盖率实测 **0.0%** —— 一个判据都没有。
//
// 而这个包的注释里明确写着它要消灭的缺陷形态:
//
// StopRTChild(单数)注销入站登记,StopRTChildren(复数)漏了,
// 残留随 create/destroy 单调累积且只能重启清。
//
// 也就是说:它存在的**理由**就是"单数与复数共用同一条清理路径"。
// 那条不变量没有判据,等于这次重构的核心承诺无人守。
//
// ## 判据放在哪里
//
// `child/test/`(黑箱)。理由见 engineering-lessons §5.3.1:
// Go 按目录划分包实例,子目录里写 `package child` 也访问不到未导出符号,
// 所以这个位置**物理上强制**只能调导出接口。
import (
"context"
"testing"
"github.com/JianFeeeee/HologramAgent/internal/agent/core/child"
)
// fakeEntry 是最小的 Entry 实现,只记录自己被怎么处理过。
//
// 它属于**测试替身**,不是绕过生产路径:生产里 Entry 的实现是 rtChild /
// residentChild,它们对骨架的用法就是"Base 返回公共部分 + Dispose 收尾"。
type fakeEntry struct {
base *child.Child
kind child.Kind
disposed int
disposeMsg string
}
func newFake(id string, kind child.Kind) *fakeEntry {
ctx, cancel := context.WithCancel(context.Background())
return &fakeEntry{
base: &child.Child{ID: id, Ctx: ctx, Cancel: cancel},
kind: kind,
}
}
func (f *fakeEntry) Base() *child.Child { return f.base }
func (f *fakeEntry) Kind() child.Kind { return f.kind }
func (f *fakeEntry) Dispose() string {
f.disposed++
return f.disposeMsg
}
// TestAddRejectsDuplicateID 钉住:同名登记必须被拒(不静默覆盖)。
//
// 静默覆盖会让上一代的残留(通道 Owner、主体策略、留档)被当成这一代的 ——
// 这正是 registry.go 里 Add 的注释写明要避免的。
//
// 变异:把 `if _, dup := r.byID[b.ID]; dup { return ErrChildExists }`
// 改成覆盖 ⇒ 本例红。
func TestAddRejectsDuplicateID(t *testing.T) {
var r child.Registry
if err := r.Add(newFake("a", "rt")); err != nil {
t.Fatalf("首次登记应成功: %v", err)
}
err := r.Add(newFake("a", "rt"))
if err == nil {
t.Fatal("★ 同名重复登记没有被拒 —— 上一代残留会被当成这一代的")
}
if r.Len() != 1 {
t.Fatalf("重复登记后 Len()=%d,期望 1(不许被覆盖或追加)", r.Len())
}
}
// TestAddRejectsEmptyOrNil 钉住输入校验三条。
//
// 变异:删掉 Add 开头的守卫 ⇒ 本例红(会登记一个 id 为空的子,
// 之后 Get("") 能查到它,清理路径也会拿空 id 去撤销策略)。
func TestAddRejectsEmptyOrNil(t *testing.T) {
var r child.Registry
if err := r.Add(nil); err == nil {
t.Fatal("★ nil Entry 被接受了")
}
e := newFake("x", "rt")
e.base = nil
if err := r.Add(e); err == nil {
t.Fatal("★ Base() 为 nil 的 Entry 被接受了")
}
if err := r.Add(newFake("", "rt")); err == nil {
t.Fatal("★ ID 为空的 Entry 被接受了")
}
if r.Len() != 0 {
t.Fatalf("三次非法输入后 Len()=%d,期望 0", r.Len())
}
}
// TestAddWritesKindAndCreatedAt 钉住输出:Add 会把 Kind 与 CreatedAt 写回 Base。
//
// 这是 Base 的**对外可观测状态**(状态面/排序都读它),属于契约。
func TestAddWritesKindAndCreatedAt(t *testing.T) {
var r child.Registry
e := newFake("a", "resident")
if err := r.Add(e); err != nil {
t.Fatal(err)
}
if e.base.Kind != "resident" {
t.Fatalf("Base.Kind = %q,期望 resident(Add 应写入 Entry.Kind())", e.base.Kind)
}
if e.base.CreatedAt == 0 {
t.Fatal("★ Base.CreatedAt 未被写入 —— 状态面排序会失去依据")
}
}
// TestStopOneAndStopAllShareCleanup 钉住本包存在的**理由**:
// 单数与复数走同一条清理路径,四步副作用一步不少。
//
// 变异:让 StopAll 复制一份循环体、漏掉 UnregisterInbound ⇒ 本例红。
func TestStopOneAndStopAllShareCleanup(t *testing.T) {
type trace struct {
disposed int
revoked []string
unreg []string
onDispose int
}
run := func(stop func(*child.Registry, child.StopDeps), useAll bool) trace {
var r child.Registry
var tr trace
a := newFake("a", "rt")
b := newFake("b", "rt")
a.base.InboundCh = "child/a"
b.base.InboundCh = "child/b"
_ = r.Add(a)
_ = r.Add(b)
deps := child.StopDeps{
RevokePolicy: func(id string) { tr.revoked = append(tr.revoked, id) },
UnregisterInbound: func(ch string) { tr.unreg = append(tr.unreg, ch) },
OnDispose: func(child.Entry) { tr.onDispose++ },
}
if useAll {
if n := r.StopAll("", deps); n != 2 {
t.Fatalf("StopAll 停了 %d 个,期望 2", n)
}
} else {
for _, id := range []string{"a", "b"} {
if err := r.StopOne(id, deps); err != nil {
t.Fatalf("StopOne(%s): %v", id, err)
}
}
}
tr.disposed = a.disposed + b.disposed
return tr
}
one := run(nil, false)
all := run(nil, true)
for _, c := range []struct {
name string
one, all trace
}{{"stopOne vs stopAll", one, all}} {
if c.one.disposed != 2 || c.all.disposed != 2 {
t.Fatalf("[%s] Dispose 次数 %d/%d,各期望 2", c.name, c.one.disposed, c.all.disposed)
}
if len(c.one.revoked) != 2 || len(c.all.revoked) != 2 {
t.Fatalf("[%s] RevokePolicy 次数 %d/%d,各期望 2 ——\n"+
"主体策略不撤销会让同名重建继承旧策略(engineering-lessons §3.2)",
c.name, len(c.one.revoked), len(c.all.revoked))
}
if len(c.one.unreg) != 2 || len(c.all.unreg) != 2 {
t.Fatalf("[%s] UnregisterInbound 次数 %d/%d,各期望 2 ——\n"+
"这就是历史上「单数注销、复数漏了」的缺陷形态",
c.name, len(c.one.unreg), len(c.all.unreg))
}
if c.one.onDispose != 2 || c.all.onDispose != 2 {
t.Fatalf("[%s] OnDispose 次数 %d/%d,各期望 2", c.name, c.one.onDispose, c.all.onDispose)
}
}
}
// TestStopOneUnknownReturnsError 钉住单数语义:不存在时返回错误。
//
// 与 StopAll 的"返回停了多少个"形成对比 —— 两者语义不同是有意的。
func TestStopOneUnknownReturnsError(t *testing.T) {
var r child.Registry
if err := r.StopOne("nope", child.StopDeps{}); err == nil {
t.Fatal("★ StopOne 对不存在的 id 返回了 nil")
}
}
// TestStopAllFiltersByKind 钉住 StopAll 的过滤语义。
//
// 变异:忽略 kind 参数(总是停全部)⇒ 本例红。
func TestStopAllFiltersByKind(t *testing.T) {
var r child.Registry
rt := newFake("rt1", "rt")
res := newFake("res1", "resident")
_ = r.Add(rt)
_ = r.Add(res)
n := r.StopAll("rt", child.StopDeps{})
if n != 1 {
t.Fatalf("StopAll(rt) 停了 %d 个,期望 1", n)
}
if rt.disposed != 1 {
t.Fatal("★ rt 子没有被清理")
}
if res.disposed != 0 {
t.Fatal("★ 驻留子被误清理了 —— StopAll 没按 kind 过滤")
}
if r.Len() != 1 {
t.Fatalf("剩余 %d 个,期望 1(驻留子应还在)", r.Len())
}
}
// TestRemoveReturnsEntryOnce 钉住:Remove 摘出并返回,第二次得 nil。
//
// 变异:Remove 不删除(只查)⇒ 本例红。
func TestRemoveReturnsEntryOnce(t *testing.T) {
var r child.Registry
e := newFake("a", "rt")
_ = r.Add(e)
if got := r.Remove("a"); got == nil {
t.Fatal("★ 首次 Remove 返回 nil")
}
if got := r.Remove("a"); got != nil {
t.Fatal("★ 第二次 Remove 仍返回了条目 —— 没真正删除")
}
if r.Len() != 0 {
t.Fatalf("Len()=%d,期望 0", r.Len())
}
}
// TestByKindAndIDs 钉住查询视图。
func TestByKindAndIDs(t *testing.T) {
var r child.Registry
_ = r.Add(newFake("a", "rt"))
_ = r.Add(newFake("b", "resident"))
_ = r.Add(newFake("c", "rt"))
if n := len(r.ByKind("rt")); n != 2 {
t.Fatalf("ByKind(rt) 返回 %d 个,期望 2", n)
}
ids := r.IDs()
if len(ids) != 3 {
t.Fatalf("IDs() 返回 %d 个,期望 3", len(ids))
}
for i := 1; i < len(ids); i++ {
if ids[i-1] > ids[i] {
t.Fatalf("IDs() 未排序: %v(调用方据此做稳定输出)", ids)
}
}
}
// TestChildStopIsIdempotent 钉住 Base().Stop() 幂等且会 cancel ctx。
//
// 变异:Stop 不 cancel ⇒ 本例红(正在跑的轮次不会在安全点退出)。
func TestChildStopIsIdempotent(t *testing.T) {
e := newFake("a", "rt")
e.base.Stop()
e.base.Stop() // 第二次不得 panic
if !e.base.Stopped() {
t.Fatal("Stop 后 Stopped() 为 false")
}
if e.base.Ctx.Err() == nil {
t.Fatal("★ Stop 没有 cancel ctx —— 正在跑的轮次不会退出")
}
}

View File

@ -1,6 +1,7 @@
package io
import (
"strconv"
"sync"
"testing"
)
@ -84,11 +85,23 @@ func TestConsumeToolBlocksFor_ConcurrentNoCrossTalk(t *testing.T) {
m := NewIOManager()
const n = 32
// ★ 每个 goroutine 必须用**唯一**的 id(2026-11-04 修)。
//
// 原来写的是 `id := string(rune('A' + i%26))` —— n=32 而字母只有 26 个,
// 于是 i=0 与 i=26 都得到 'A',6 个 id 各被两个 goroutine 共用。
// 两者共享同一个 key:后 Set 的覆盖先 Set 的、先 Consume 的拿到空或
// 别人的值 ⇒ 判据约 1/8 概率假红。
//
// 实测该失败在**本次改动之前**的基线上同样复现(12 次内中 1 次),
// 报错 `call E 期望 [block-4-E],实际 []`。这是**测试自身的缺陷**,
// 不是 SetToolBlocksFor / ConsumeToolBlocksFor 的并发缺陷 ——
// 两个 goroutine 抢同一个 key 本就该串话(那是覆盖语义,不是串话)。
var wg sync.WaitGroup
errs := make(chan string, n)
for i := 0; i < n; i++ {
id := string(rune('A' + i%26))
want := "block-" + string(rune('0'+i%10)) + "-" + id
// id 唯一 ⇒ 每个调用有自己独立的 key,断言才有意义。
id := "call-" + strconv.Itoa(i)
want := "block-" + strconv.Itoa(i%10) + "-" + id
wg.Add(1)
go func() {
defer wg.Done()
@ -106,6 +119,50 @@ func TestConsumeToolBlocksFor_ConcurrentNoCrossTalk(t *testing.T) {
}
}
// TestConsumeToolBlocksFor_KeysAreIsolated 钉住"不同 key 互不干扰"这条契约。
//
// ## 为什么单开一条(2026-11-04)
//
// 上面那条并发判据原先用 `i%26` 生成 id,n=32 ⇒ 6 个 id 重复,两个 goroutine
// 抢同一个 key ⇒ 约 1/8 概率假红。它红了半年没人管,因为"偶发"通常被当成
// 环境问题。
//
// 根因是判据**自己违反了它想验证的前提**:想测"不同 key 不串话",
// 却让多个 goroutine 共用 key。
//
// 这条判据把前提本身钉住:一次性写入 N 个**互不相同**的 key,
// 逐个读回必须各得其所。它不涉及并发,所以失败一定是真缺陷。
func TestConsumeToolBlocksFor_KeysAreIsolated(t *testing.T) {
m := NewIOManager()
const n = 64
want := make([]string, n)
for i := 0; i < n; i++ {
id := "call-" + strconv.Itoa(i)
want[i] = "block-" + strconv.Itoa(i)
m.SetToolBlocksFor(id, []interface{}{want[i]})
}
for i := 0; i < n; i++ {
id := "call-" + strconv.Itoa(i)
got := m.ConsumeToolBlocksFor(id)
if len(got) != 1 || got[0] != want[i] {
t.Fatalf("★ key %s 期望 [%s],实际 %s ——\n"+
"不同 key 之间串话了(或写入被覆盖)", id, want[i], toStr(got))
}
}
}
// TestConsumeToolBlocksFor_ConsumeIsOneShot 钉住消费语义:读一次后清空。
func TestConsumeToolBlocksFor_ConsumeIsOneShot(t *testing.T) {
m := NewIOManager()
m.SetToolBlocksFor("c", []interface{}{"b"})
if got := m.ConsumeToolBlocksFor("c"); len(got) != 1 {
t.Fatalf("首次消费应得 1 条,实际 %d", len(got))
}
if got := m.ConsumeToolBlocksFor("c"); len(got) != 0 {
t.Fatalf("★ 二次消费应得空(block 是一次性的),实际 %s", toStr(got))
}
}
func toStr(b []interface{}) string {
s := "["
for i, v := range b {