根因(前一条 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 线合入。
5.3 KiB
ID:10 / KERNEL-REGISTRY-SETLOCK 收口补丁(CI 侧)
这个文件是什么
ID:10 的收口要求里,有三条是**落在 CI 线(ci/boot-graph-tests)**上的, 不在本分支(fix/registry-setlock,基于 f408448)的改动范围内:
- 删掉 ci.yml race 门排除表里
internal/kernelboot/modules那一行 - 删掉 evidence 步骤 ② 的内核 setter 静态断言
- 更新那段"整包永久排除不被接受"的警告框为收口记录
本分支不许碰 ci.yml(任务边界),所以这三步没有在本分支落地, 而是做成下面这份可评审的补丁,交给 CI 线合入。
补丁内容(已在本机实测通过,见下)
patch: id10_ci_closeout.patch(相对路径 a/.github/workflows/ci.yml,
可直接 git apply;对 ci/boot-graph-tests@efe0cf1 的 ci.yml)
改动只有三处,全部是"删排除 + 记录收口",无新增门禁逻辑:
① 排除表:删掉 pkg|internal/kernelboot/modules|…|KERNEL-REGISTRY-SETLOCK 整行
⇒ 该包重新纳入 -race(这正是本次修复的目的:它现在跑 -race 是绿的)
② evidence 步骤:删掉 ② 整段(查 7 个 Set* 是否仍"单行无锁直写"的静态断言) ⇒ 它存在的唯一目的是"修好后提醒去删排除项",排除项已删,它已完成使命 ⇒ 收尾提示语从"两个排除项…仍在"改为"webui 排除项…仍在"
③ 警告框:把"唯一出口是给 Set* 加锁,修好后做三步"改成"已收口"记录, 并写清"去重不能替代加锁"的实测结论
为什么删完排除项后门禁仍然有效(实跑验证)
排除表删到只剩 webui 一项后:
· pkgs= 为空 —— 剔除循环 for e in $EXCL 不执行,无断言误报
· tests=TestOpenAIStreamIsActuallyStreaming —— test 级排除照常生效
· count=1 —— 表还在,机制没被架空
实测(本机 bash 跑该步骤原文):
════════ -race 排除表 ════════
[1] test 级: TestOpenAIStreamIsActuallyStreaming
包: internal/plugins/webui
原因: gzipResponseWriter 的 mode/wroteHeader/buf 无锁;…
修复单: WEBUI-GZIP-RACE
════════ 共排除 1 项(不是静默跳过:每次 CI 都打印本表)════════
EXIT=0
--- GITHUB_OUTPUT ---
tests=TestOpenAIStreamIsActuallyStreaming
pkgs=
count=1
为什么这个补丁不是"拍脑袋认为 CI 会绿"
按 ID:10 收口要求第 2 条「复跑一次 -race 确认整机判据在 -race 下也稳定」, 在修复后的代码上重复跑了 25 次整机判据(未删排除项条件下的同一条命令):
go test -race ./internal/kernelboot/modules/ -count=1 -timeout 10m
修复后:25/25 次 0 条 DATA RACE,全部 ok(本线 f408448 + 修复补丁实测)
修复前(同一棵树、同一命令):2/25 次报红,累计 14 条 DATA RACE
修复前那次的原始栈(证明命中的正是本单缺陷,不是别的竞争):
WARNING: DATA RACE
… internal/kernelmod/agent.wirePluginSDK()
internal/kernelmod/agent/mod.go:251 (SetLuaVM)
internal/kernelmod/agent/mod.go:252 (SetBaseAPIKey)
… :253 :254 :255 :256 :257
… internal/kernelmod/pluginhost.injectBaseDeps()
internal/kernelmod/pluginhost/mod.go:289 (SetLuaVM)
… :290 :292 :294 :295 :296 :299
… internal/plugin/registry.go:231 (SetLuaVM 无锁直写)
… internal/plugin/registry.go:232 (SetBaseAPIKey 无锁直写)
… internal/plugin/registry.go:271 / :272
即:agent 与 plugin-host 两个 goroutine 在第 4 层并行写同一批字段, 与 ID:10 描述完全一致。
另一个边界:去重不能替代加锁(实测)
本次同时做了"去掉重复注入",但它替代不了加锁。做了对照实验: 回退 registry.go 到未加锁版、只保留 agent 侧去重(即 injectBaseDeps 不动), 然后人为制造"去重后仍然存在的写-读并发":
写侧:agent 侧去重后**保留**的 SetStatusProvider
读侧:pluginhost 侧加载插件时 buildSDK 读 r.status
结果:1 条 DATA RACE(SetStatusProvider vs buildSDK),即去重后竞争仍在。 所以 registry 侧的锁才是修复本体,去重只是消除了 7 项无意义的重复写。
应用方式
在 ci/boot-graph-tests 分支上:
git checkout ci/boot-graph-tests
git apply id10_ci_closeout.patch
python3 .github/scripts/check-ci-gates.py # 应通过
git commit -m "ci: ID:10 收口 —— 删掉 kernelboot/modules 的 -race 排除项"
验收清单(全部已在本机实测)
[x] go build ./... 0 [x] go vet ./... 0 [x] gofmt(改动文件) 干净 [x] check-ci-gates.py 通过(EXIT=0) [x] 排除表步骤实跑 EXIT=0,pkgs 为空、tests 有值 [x] -race 整机判据 25 次 0 条 DATA RACE [x] -race 内核三包全量(kernelboot/plugin/kernelmod) 全 ok,0 条 DATA RACE [x] 门禁脚本仍能咬住(变异 A:缺原因 ⇒ EXIT=1) ✔ [x] 门禁脚本仍能咬住(变异 B:判空改 false + 留文案 ⇒ EXIT=1) ✔