Files
HomeAgent/ID10_CI_CLOSEOUT.md
JianFeeeee 19c78ca946 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 线合入。
2026-10-24 12:17:46 +08:00

113 lines
5.3 KiB
Markdown
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.

ID:10 / KERNEL-REGISTRY-SETLOCK 收口补丁(CI 侧)
================================================
## 这个文件是什么
ID:10 的收口要求里,有三条是**落在 CI 线(ci/boot-graph-tests)**上的,
不在本分支(fix/registry-setlock,基于 f408448)的改动范围内:
1. 删掉 ci.yml race 门排除表里 `internal/kernelboot/modules` 那一行
2. 删掉 evidence 步骤 ② 的内核 setter 静态断言
3. 更新那段"整包永久排除不被接受"的警告框为收口记录
本分支**不许碰 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) ✔