fix(plugin): Registry 的 Set* 全部取写锁,读侧改一致性快照(KERNEL-REGISTRY-SETLOCK)

根因(前一条 CI 线实测):internal/plugin/registry.go 的 Set* 长期是单行
无锁直写,而 r.mu 一直存在却从未被这批 setter 使用;同时
kernelmod/agent.wirePluginSDK 与 kernelmod/pluginhost.injectBaseDeps
同处启动图第 4 层、并行执行,各自把同一批字段写一遍 ⇒ 写-写竞争。

## 一、加锁(真正的修复)

· 24 个 Set* 全部改为 r.mu.Lock() / defer Unlock():
  SetIOManager/SetEventBus/SetMemory/SetTextMemory/SetDocStore/SetKnowledge/
  SetProviderManager/SetConfigRegistry/SetPluginDir/SetDataDir/SetLuaVM/
  SetBaseAPIKey/SetKernelControl/SetToolRegistrar/SetStageRegistrar/
  SetAPIRegistrar/SetToolCleaner/SetStatusProvider/SetSupervisor/SetTracker/
  SetConfig/SetStageHost/SetDeviceAuthQuery/SetIndexer
· 读侧保住 RLock,并新增 sdkDeps + snapshotDeps():buildSDK 在**一次**
  RLock 内取齐整组依赖后立刻放锁,随后用快照装配 SDK。
  为什么不在整个 buildSDK 外套 RLock:其闭包(regOutput 等)在插件 Start
  期间才被调用,持锁跨用户代码有死锁风险;改为快照 + 放锁。
  为什么必须一次取齐:逐字段裸读既各自是一处竞争,也会让同一次装配
  看到"lua 已换新、cfg 还是旧"的撕裂组合。
· kernelControlFor / stageRegistrarFor 拆出无锁内核
  (kernelControlForCtl / stageRegistrarForSource),供快照后复用,
  避免持锁再 RLock 的自锁。
· 其余读点集中走访问器:pluginDirOrEmpty / cfgRegistryOrNil /
  toolCleanerOrNil / stageHostOrNil / eventBusOrNil / ioManagerOrNil
  (dynamic_proc.go 的 evBus 两处同步改)。
· 未改任何语义、未重命名导出符号。

## 二、去重复注入(可行,已做)

按"必要性不对称"判定:保留 pluginhost.injectBaseDeps,砍掉
agent.wirePluginSDK 里的 7 项重复写,agent 侧只留 SetStatusProvider。
理由:pluginhost 紧接着就 LoadSkipping 拉插件,且它刻意 Requires() 不含
agent("插件不必等 agent 35 秒"的收益就在这半边)——砍它会让第 4 层加载
的插件拿到全 nil 依赖;而 agent 侧写的是同一批 ks.* 的同一份值,写第二次
无新增信息。

★ 去重**不能**替代加锁:去重后 agent 仍写 SetStatusProvider,pluginhost
同时可能 buildSDK 读 status,写-读竞争仍在(实测可复现 1 条 DATA RACE)。
所以锁是修复本体,去重只是消除无意义的重复写。

## 三、验证(全部 -count=1)

判据(internal/plugin/registry_setlock_test.go):
  · TestRegistrySettersTakeWriteLock —— 源码级:24 个 Set* 函数体必须各含
    r.mu.Lock()(race 是概率性的,"锁被删了"这类回退必须确定性咬住)
  · TestSettersConcurrentWriteRace —— 两个写 goroutine 用不同值并发写同一批
    字段 + 读侧并行
  · TestBuildSDKConcurrentWithSetters —— 走真实读路径 buildSDK/snapshotDeps
    与 setter 并发(补上一条测不到的**读侧**缺口)

变异实证(每条都实跑,均报红):
  M1 删掉 SetLuaVM 的锁        ⇒ 源码判据报红 + -race 报 DATA RACE
  M2 删掉 SetConfig 的锁        ⇒ 源码判据报红 + TestBuildSDKConcurrent 报红
  M3 只删 snapshotDeps 的 RLock ⇒ -race 报红,栈指向读侧 :486/:494/:495
                                  vs 写侧 :274/:367(证明读侧回退也有人管)

race 数字(CI 线整机判据 go test -race ./internal/kernelboot/modules/):
  修复前 25 轮:2 轮报红,累计 14 条 DATA RACE
  修复后 25 轮:0 轮报红,0 条 DATA RACE
范围包(./internal/plugin/... ./internal/kernelmod/...)修复后 -race 全绿。
  go build ./... / go vet ./... 干净。

## 四、CI 侧收口(不在本分支落地)

ci/boot-graph-tests 的 race 排除表里那条
  pkg|internal/kernelboot/modules|…|KERNEL-REGISTRY-SETLOCK
现在可以删了。按任务边界本分支不碰 ci.yml,改由
id10_ci_closeout.patch(对 ci/boot-graph-tests 可干净 git apply)+
ID10_CI_CLOSEOUT.md 交给 CI 线合入。
This commit is contained in:
JianFeeeee
2026-10-24 12:17:46 +08:00
parent f408448c37
commit 19c78ca946
6 changed files with 827 additions and 113 deletions

View File

@ -0,0 +1,260 @@
package plugin
// KERNEL-REGISTRY-SETLOCK(2026-10-24):Registry Set* 注入路径的**写-写竞争**判据。
//
// ## 为什么必须有这条判据
//
// Registry 上那批 Set*(SetLuaVM / SetBaseAPIKey / SetStageHost / SetIndexer …)
// 长期是单行无锁直写:`func (r *Registry) SetLuaVM(vm *luaVM.VM) { r.lua = vm }`。
// 结构体里明明有 r.mu 这个 RWMutex,但这批 setter 一次都没取过锁。
//
// 平时看不出来,因为注入通常发生在启动早期。内核模块化之后就不成立了:
// **两个 goroutine 会同时注入同一批字段** ——
//
// internal/kernelmod/agent/mod.go wirePluginSDK → SetLuaVM/SetBaseAPIKey/SetStageHost/…
// internal/kernelmod/pluginhost/mod.go injectBaseDeps → SetLuaVM/SetBaseAPIKey/SetStageHost/…
//
// 两者同属启动图第 4 层、并行执行,于是 r.lua / r.baseKey / r.stageHost / r.idx
// 被两个 goroutine 同时写。Go 内存模型下这是 data race;interface 字段尤其危险
// (type 与 data 两字不是同一轮写入 ⇒ 读到撕裂值,调用时直接崩)。
//
// ## 两条判据,一快一准
//
// 1. TestSettersConcurrentWriteRace —— **运行时**判据:并发 Set + 并发读,
// -race 下未加锁必然报 DATA RACE。这是正面证据(真跑真竞争)。
// 2. TestRegistrySettersTakeWriteLock —— **源码级**判据:逐个 setter 检查
// 函数体内必须出现 r.mu.Lock()。race 是概率性的,这条把"有人把锁删了"
// 这类回退 100% 挡在门口(变异实验里它是确定性命中的那一条)。
//
// ★ 两条都必须"活的":把任一 setter 的锁去掉(换回直写),两条都要报红。
// ★ 本文件只依赖 Registry 的公开 setter 与 r.mu 本身,不引用任何内部访问器 ——
// 因此在**未修复**与**已修复**两版 registry.go 上都能编译,
// 才可能拿它当"修前红 / 修后绿"的**同一条**判据。
import (
"go/ast"
"go/parser"
"go/token"
"os"
"runtime"
"sync"
"testing"
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
"github.com/JianFeeeee/HologramAgent/internal/lua"
"github.com/JianFeeeee/HologramAgent/internal/memory"
sdk "github.com/JianFeeeee/HologramAgent/internal/sdk"
)
// setlockRounds 是每个注入 goroutine 的轮数。
//
// 取值依据:单轮 6 次写 × 2 个写 goroutine,未加锁代码上 -race 稳定命中
// (实测连续多次运行均报红,见交付证据);再往上只是拖长测试。
const setlockRounds = 300
// TestSettersConcurrentWriteRace 钉住 setter 之间的写-写竞争。
func TestSettersConcurrentWriteRace(t *testing.T) {
r := NewRegistry()
// 两组 goroutine 用**不同的值**写同一批字段:
// 写相同值容易被优化掉,不同值才能让 race detector 看到
// "读到组合了两次写入的状态"。
vmA, vmB := &lua.VM{}, &lua.VM{}
shA := &raceToolSource{tag: "A"}
shB := &raceToolSource{tag: "B"}
idxA := &memory.Indexer{}
idxB := &memory.Indexer{}
// IOManager 只作为被写入/读回的句柄,判据不调其方法。
iomA, iomB := &agentIO.IOManager{}, &agentIO.IOManager{}
// ★ 目录只取一次:放在循环里 t.TempDir() 会造出上万个临时目录。
const dirA, dirB = "dir-A", "dir-B"
inject := func(vm *lua.VM, key, dir string, sh sdk.ToolSource, idx *memory.Indexer, iom *agentIO.IOManager) {
for i := 0; i < setlockRounds; i++ {
// 与 wirePluginSDK / injectBaseDeps 交叠的核心项
r.SetLuaVM(vm)
r.SetBaseAPIKey(key)
r.SetStageHost(sh)
r.SetIndexer(idx)
// 同一批装配里相邻的另外两项
r.SetIOManager(iom)
r.SetPluginDir(dir)
}
}
var writers sync.WaitGroup
writers.Add(2)
go func() { defer writers.Done(); inject(vmA, "key-A", dirA, shA, idxA, iomA) }()
go func() { defer writers.Done(); inject(vmB, "key-B", dirB, shB, idxB, iomB) }()
// 读侧并行:真实内核里 buildSDK 读这些字段与注入是交织的。
// 读侧持 RLock(本次修复不动读侧)—— 同时验证"没有把并发读改成串行"。
stop := make(chan struct{})
var readers sync.WaitGroup
readers.Add(1)
go func() {
defer readers.Done()
for {
select {
case <-stop:
return
default:
vm, key, sh, idx := r.injectedSnapshot()
// 必须真的用掉读到的值,否则编译器可能把读优化掉,
// race detector 就看不到这次读。
if vm == nil && key != "" && sh != nil && idx == nil {
t.Error("setlock: 读到不可能的注入组合(读侧失去意义)")
return
}
}
}
}()
writers.Wait()
close(stop)
readers.Wait()
}
// injectedSnapshot 以 RLock 一次性读齐 4 个注入字段(模拟 buildSDK 读侧)。
func (r *Registry) injectedSnapshot() (*lua.VM, string, sdk.ToolSource, *memory.Indexer) {
r.mu.RLock()
defer r.mu.RUnlock()
return r.lua, r.baseKey, r.stageHost, r.idx
}
// raceToolSource 是 sdk.ToolSource 的最小实现(判据里只需要个非 nil 值)。
type raceToolSource struct{ tag string }
func (s *raceToolSource) GetToolDefs() []sdk.ToolDef { return nil }
func (s *raceToolSource) ToolDef(string) *sdk.ToolDef { return nil }
func (s *raceToolSource) ExecuteTool(string, map[string]interface{}) (interface{}, error) {
return s.tag, nil
}
// registrySetterNames 是需要持写锁的 setter 清单(与 registry.go 对应)。
// 新增 Set* 时必须同步登记 —— 源码判据按这份清单咬。
var registrySetterNames = []string{
"SetIOManager", "SetEventBus", "SetMemory", "SetTextMemory",
"SetDocStore", "SetKnowledge", "SetProviderManager", "SetConfigRegistry",
"SetPluginDir", "SetDataDir", "SetLuaVM", "SetBaseAPIKey",
"SetKernelControl", "SetToolRegistrar", "SetStageRegistrar", "SetAPIRegistrar",
"SetToolCleaner", "SetStatusProvider", "SetSupervisor", "SetTracker",
"SetConfig", "SetStageHost", "SetDeviceAuthQuery", "SetIndexer",
}
// TestRegistrySettersTakeWriteLock 是源码级判据:每个 Registry.Set* 函数体里
// 必须出现 r.mu.Lock()。
//
// 为什么需要它(与运行时判据并存):
//
// race detector 依赖"这一次运行恰好交错到了",是概率性的。
// 而"某人把锁删了"这类回退必须 100% 被挡在 CI 门口 —— 源码判据确定性命中。
func TestRegistrySettersTakeWriteLock(t *testing.T) {
_, thisFile, _, _ := runtime.Caller(0)
src, err := os.ReadFile(thisFile[:len(thisFile)-len("registry_setlock_test.go")] + "registry.go")
if err != nil {
t.Fatalf("读 registry.go 失败: %v", err)
}
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "registry.go", src, 0)
if err != nil {
t.Fatalf("解析 registry.go 失败: %v", err)
}
locked := map[string]bool{}
for _, decl := range f.Decls {
fd, ok := decl.(*ast.FuncDecl)
if !ok || fd.Recv == nil || fd.Name == nil || fd.Body == nil {
continue
}
name := fd.Name.Name
if len(name) < 4 || name[:3] != "Set" {
continue
}
// 函数体内是否出现 r.mu.Lock()。
//
// ★ 形状注意:`r.mu.Lock()` 的 CallExpr.Fun 是
// SelectorExpr{ Sel: "Lock", X: SelectorExpr{ Sel: "mu", X: Ident{"r"} } }
// 不是 X 直接为 Ident —— 按 x.Name == "r" 判会漏掉**全部**加锁的 setter,
// 判据变成"恒报红",等于没判(本判据第一版就踩了这个坑)。
found := false
ast.Inspect(fd.Body, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok || sel.Sel == nil || sel.Sel.Name != "Lock" {
return true
}
mu, ok := sel.X.(*ast.SelectorExpr)
if !ok || mu.Sel == nil || mu.Sel.Name != "mu" {
return true
}
if id, ok := mu.X.(*ast.Ident); ok && id.Name == "r" {
found = true
}
return true
})
if found {
locked[name] = true
}
}
for _, want := range registrySetterNames {
if !locked[want] {
t.Errorf("Registry.%s 未取写锁(r.mu.Lock)—— "+
"这会重新引入启动图第 4 层并行注入的 DATA RACE(KERNEL-REGISTRY-SETLOCK)。"+
"修法:函数体开头 r.mu.Lock(); defer r.mu.Unlock()", want)
}
}
}
// TestBuildSDKConcurrentWithSetters 钉住**真实读路径**与 Set* 的读写竞争。
//
// 为什么需要它(前两条判据覆盖不到的地方):
//
// TestSettersConcurrentWriteRace 的读侧用的是本文件自己写的 injectedSnapshot
// ——它**自带 RLock**,所以只证明了"setter 之间不再互撞",
// 完全测不到 registry.go 里 buildSDK / snapshotDeps 那条真实读路径。
// 把 snapshotDeps 的 RLock 删掉,前两条判据仍会全绿(实测过)——
// 那就是判据缺口:read 侧的回退没人管。
//
// 本判据走 buildSDK(插件加载时真实调用的函数),setter 并发写同一批字段:
// 读侧若失去 RLock,-race 必报,且报告栈会直接指向 registry.go 的读点。
func TestBuildSDKConcurrentWithSetters(t *testing.T) {
r := NewRegistry()
r.SetPluginDir(t.TempDir())
r.SetDataDir(t.TempDir())
const rounds = 200
var wg sync.WaitGroup
// 写侧:模拟 agent 与 pluginhost 两个模块并行注入。
wg.Add(2)
go func() {
defer wg.Done()
for i := 0; i < rounds; i++ {
r.SetLuaVM(nil)
r.SetConfig(nil)
}
}()
go func() {
defer wg.Done()
for i := 0; i < rounds; i++ {
r.SetStageHost(nil)
r.SetIndexer(nil)
}
}()
// 读侧:走真实路径 buildSDK(内部 snapshotDeps 取依赖快照)。
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < rounds; i++ {
_ = r.buildSDK("setlock-probe")
}
}()
wg.Wait()
}