mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 06:47:29 +00:00
根因(前一条 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 线合入。
261 lines
9.4 KiB
Go
261 lines
9.4 KiB
Go
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()
|
||
}
|