Files
HomeAgent/internal/kernelboot/modules/registry.go
JianFeeeee ed8f28ea6c test(kernelboot,ci): 用真实启动图装配整机补判据,race 门由写死两包扩到全仓
背景:老大指出「CI 测试不够灵活、覆盖不到内核方方面面,应从启动图入手」。
先定位 CI 失败:六个门禁在基线上本地全绿,复现不出红 —— 实质是**覆盖面太窄**
而非配置写错(原 race 门只跑 ./internal/agent/core/ 与 ./cmd/waiter/ 两个包,
kernelboot 下 scheduler/syncbus/stress 的并发用例全在裸奔,绿是因为没测到)。

## 一、新增 internal/kernelboot/modules/registry_test.go(913 行,5 条判据)

kernelboot 自身判据很密(图编解码/分层/调度/迁移回滚/并发压测),但**全部用
假模块**在图上验算法。「真实 17 个模块按真实登记表装配成整机、逐层启动、
再严格逆序关闭」一条用例都没有。这中间藏着一整类失败:算法对、单模块对,
但装配起来不对(漏挂模块 / 某层关闭函数没登记 / 关闭顺序反了)。

5 条判据(零假模块,全部基于 kernelmod.Registered() 的 17 个真模块):
- TestRealRegistryAssemblesWholeMachine:每模块进图、设施真就绪(不是"登记过")
- TestLayerTopologyIsRespected:层间严格有序、层内两两无依赖、分层真带来并行
- TestShutdownIsStrictlyReverseLayerOrder:关闭严格逆序(实测哨兵序 [4 3 2 1 0])
- TestShutdownLeavesNoResidue:连续启停 3 轮,goroutine 回基线 + 临时目录删净
  + syncbus 订阅者不增长
- TestEveryRegisteredModuleIsCompiledIn:源码树扫描与登记表**双向**集合比对

### 判据 5 的真相源为何是"类型+四方法集"而不是 Register 调用点(初版栽过)

初版扫 `kernelmod.Register(...)` 调用点。实测:删掉叶子模块 multimodal 的
init() 后,**5 条判据全绿** —— 调用点消失 ⇒ 源码侧与登记表侧同时少一个 ⇒
集合比对照样相等。而"忘记初始化注册"恰是它声称要抓的场景。
根因是把「要检测的对象」当成了「检测的入口」,入口一被破坏就失去视野。
改成扫「实现了 Name/Requires/Provides/Init 四方法的类型」:模块哪怕不
Register、不被 import,四个方法仍写在源码里。
坑:本仓大量类型都叫 Module,只按类型名做键会撞成一堆(实测撞到只剩 2 个)
⇒ 用「目录+类型名」复合键;agent 是指针接收者 *Module,要处理 StarExpr。
修后恰扫出 17 个类型,与登记表 17 个名字一一对应,零误报。

### 失败面逐条实测(不是注释声称)

M1 删叶子模块 init() → 只有判据 5 红(1–4 全绿,这正是它存在的理由)
M2 CloseAll 逆序改正序 → 判据 3 红
M3 删聚合 import → 判据 5 红
其余:跳过某层会让内核 panic(llm 拿到 nil LuaVM,SIGSEGV);层顺序改逆序
红在装配失败(依赖数据为 nil)而非顺序断言 —— 这两种能发现但报错可读性差,
已写入文件注释,修它属业务改动(另派)。

## 二、.github/workflows/ci.yml

- race 门:写死两包 → `go list ./...` 全仓动态枚举(剔除 cmd/gui,
  枚举 <50 包则响亮失败),实测 92 包
- 新增 -skip 排除表:每项必须写「原因 + 修复单号」,无原因该步骤自身失败;
  每次 CI 打印完整清单,避免"排除项静默腐烂"
- 新增「已知红的可核对证据」步:用**静态断言**检查两处已知缺陷是否仍在
  (webui gzipResponseWriter 无锁、kernel Set* 无锁直写)。
  不用"DATA RACE 报告数>0"当断言 —— 实测这些竞争偶发(正向 0 条、
  另一次 8 条),那样必然假绿。
- cross 门:cmd 清单由写死 → 逐包 `CGO_ENABLED=0 go build` 实测枚举
  (实测 4 个纯 Go / 8 个依赖 cgo;homed-key-rotate 一直是纯 Go 却从没进过
  清单,白少一个覆盖);矩阵补 windows/arm64 与 linux/386
- go job:单列「整机启动图判据」一步(带 -count=1,便于失败定位)

## 三、新增 .github/scripts/check-ci-gates.py(180 行)

官方复核时发现:第一版判据只跑 gofmt/build/vet/go test,**完全不校验 ci.yml**,
而 ci.yml 正是改动占比最大的部分 —— 用「删掉 cmd/gui 剔除」这个变异咬出了该缺口。
本脚本用 YAML 解析断言 6 类门禁机制仍在,并给每个 run 步骤做 bash -n。
关键细节:`run: |` 块里的 `# ...` 是**字符串不是 YAML 注释**,`"cmd/gui" in run`
会被一句说明性注释满足 —— 真把 grep -v 删掉断言仍绿。故剥 shell 注释后再断言,
并给剥注释函数**本身**加了自测。

## 四、两处纯注释纠偏

- modules/registry.go:文档写「防漏 import 判据见 registry_test.go」(main 上该
  文件当时不存在)、第④步指向 `modules/imports.go`(真实位置是
  internal/kernelmod/all/all.go)。已纠正并落地为两条判据的指引。
- kernelboot/kernelstate.go:把「判据见 modules」具体化为函数名。

## 门禁(全部 -count=1,无缓存)

go build ./...          = 0
go vet ./...            = 0
go test ./...           = 0(59 ok / 0 FAIL)
go test -race 91 包      = 0(58 ok / 0 FAIL)
check-ci-gates.py       = 0
证据步骤正向 = 0;反向(给 SetLuaVM 加锁)= 1(证明是活断言不是死代码)

## 已知红(均待另开线,本线不越界改业务)

1. 内核:agent.wirePluginSDK 与 pluginhost.injectBaseDeps 在第 4 层并行重复
   写同一批 plugin.Registry 字段,而 Set* 全不取锁(r.mu 存在但没用)。
   实测偶发约 4%(1/25)。当前以 pkg 级排除临时挡住,**定性为临时手段**;
   唯一出口是给 Set* 加锁(修复单 KERNEL-REGISTRY-SETLOCK)。
2. webui:gzipResponseWriter 的 mode/wroteHeader/buf 无锁,handler goroutine
   与增量写 goroutine 并发写同一 ResponseWriter(修复单 WEBUI-GZIP-RACE)。
3. internal/agent/core TestStress_Nesting... 时序偶发(-race 放大 300ms 假设,
   无 DATA RACE 报告),未 skip,复现命令记在 ci.yml 注释里。

边界:未改任何内核业务逻辑(两个业务文件 diff 逐行只有注释);未 push、未开 PR。
2026-10-24 11:30:51 +08:00

102 lines
3.6 KiB
Go
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.

// Package modules 是内核模块的**登记表**(不含任何具体模块的实现)。
//
// ## 设计意图(用户设计)
//
// > 每个内核模块显式各自声明依赖 … 最先初始化的 core 自然不依赖任何模块,
// > 下一步初始化的依赖 core 以此类推
//
// ## 怎么加一个新模块(这是本设计的**验收标准**)
//
// ① 新建一个文件(如 modules/mymod.go)
// ② 写一个类型,实现 kernelboot.Module(Name/Requires/Provides/Init)
// ③ 在同文件的 init() 里调 modules.Register(&MyMod{})
// ④ 在 `internal/kernelmod/all/all.go` 加一行空白 import —— **仅此而已**
//
// **不需要**编辑任何中央清单:依赖写在模块**自己**的 Requires() 里。
//
// ★ 为何要"防漏 import"的判据(见 `internal/kernelboot/modules/registry_test.go`):
// 忘了 import ⇒ 模块**根本不被编译进来** ⇒ 它不参与装配 ⇒ 依赖它的模块报
// "缺依赖"(响亮),但**不依赖它的模块会静默少了那个设施**
// (如插件注册表没建 ⇒ 插件全部加载不了,而错误信息指向插件而非根因)。
//
// 判据有两条(缺一不可):
//
// · `TestEveryRegisteredModuleIsCompiledIn` —— 用 AST 扫源码树里的
// `kernelmod.Register(X{})`,与登记表做集合比对(独立真相源,
// 手写清单会跟着漏,所以不用清单)
// · `TestRealRegistryAssemblesWholeMachine` 等 —— 用真实登记表装配整机、
// 断言每个模块都进图且设施就绪、并严格逆序关闭(见同目录测试文件)
package modules
import (
"fmt"
"sort"
"sync"
"github.com/JianFeeeee/HologramAgent/internal/kernelboot"
)
var (
regMu sync.Mutex
registry []kernelboot.Module
seenName = map[string]bool{}
)
// Register 由模块在自己的 init() 里调用(自注册)。
//
// 重复注册同一模块名会**记录**并在 BuildModules 时报错 —— 不用 panic:
// panic 发生在 init 阶段,栈信息对使用者毫无意义(他只想知道哪个模块重名)。
func Register(m kernelboot.Module) {
if m == nil {
return
}
regMu.Lock()
defer regMu.Unlock()
if seenName[m.Name()] {
dupErr = append(dupErr, m.Name())
return
}
seenName[m.Name()] = true
registry = append(registry, m)
}
// dupErr 累积重名模块(BuildModules 时统一报告)。
var dupErr []string
// BuildModules 返回全部已登记模块(按名字排序,保证可复现)。
//
// ## 为什么排序
//
// 分层结果的**层内顺序**若依赖 map/切片顺序,同一份声明两次构建会得到
// 不同顺序 ⇒ 不可审计(设计 §2.5:图要能进 PR diff 比对)。
// 排序让结果稳定。
func BuildModules() ([]kernelboot.Module, error) {
regMu.Lock()
defer regMu.Unlock()
if len(dupErr) > 0 {
names := append([]string(nil), dupErr...)
sort.Strings(names)
return nil, fmt.Errorf("kernelboot/modules: 模块名重复注册: %v"+
"(同一个模块被 import 了两次?或两个模块取了同名)", names)
}
out := append([]kernelboot.Module(nil), registry...)
sort.Slice(out, func(i, j int) bool { return out[i].Name() < out[j].Name() })
if len(out) == 0 {
return nil, fmt.Errorf("kernelboot/modules: 没有任何模块被登记 —— " +
"通常是没有 import 模块包(见本文件顶部的'怎么加一个新模块')")
}
return out, nil
}
// Names 返回已登记模块名(诊断/判据用)。
func Names() []string {
regMu.Lock()
defer regMu.Unlock()
out := make([]string, 0, len(registry))
for _, m := range registry {
out = append(out, m.Name())
}
sort.Strings(out)
return out
}