Files
HomeAgent/internal/kernelmod/all/bootgraph_test.go
JianFeeeee 1905d32053 docs(kernelboot): ID:14 已完成,同步 registry_test.go 失败面第 4 条
## 为什么改
bootgraph_test.go(原 internal/kernelboot/modules/registry_test.go)文件头
「失败面」一节第 4 条是**历史实测记录**,原文结句写着:

    要"红得干净"得让 kernelboot.Run 对 nil 依赖显式报错 —— 那是业务改动,
    不在本线权限内,已记入待办。

ID:14(e70c2b5)做完之后这句成了**假信息**:留着比删掉更有害 ——
下次有人照着它判断「Run 不拦 nil 依赖」,会得出与事实相反的结论。
同时 kernelstate.go 的注释已改成「Run 会显式拦」,两边口径不一致更要命。

## 改了什么(纯注释,**零行为改动**)
- 原记录的「崩在业务代码里」形态**保留**,但明确标注为「ID:14 之前的形态,
  作为为什么需要执行期校验的原始证据」
- 新增「已完成(ID:14,提交 e70c2b5)」小节,写清现在的行为与**实测**结果
- 指向专用回归判据(runtime_dep_test.go / runtimedep_test.go)

## 注释里那句「换成可读报错」是实测过的(非声称)
用真实登记表 kernelmod.Registered() 算 plan 后注入两种变异,均得
*DependencyNotReadyError,**无 panic / SIGSEGV**:
  · 整层逆序    ⇒ 第 0 层拒(produced 为空)
    模块 agent 缺设施 [docmem knowledge llm memory stagehost textmem tracker];
    模块 plugin-host 缺设施 [config docmem kernel-io knowledge lua memory
    stagehost supervisor textmem tracker]
  · 跳过第 2 层 ⇒ 第 2 层拒
    模块 stage-host 缺设施 [knowledge llm textmem]

(初稿写成「第 1 层 … 模块 llm 缺设施 [lua]」,与实测不符,已按实际输出改正。)

## 校验
git diff 中非注释行改动 = 0(grep 计数)
go build ./... = 0 error,go vet ./... = 0 warning
go test ./internal/kernelboot/ ./internal/kernelmod/all/ = ok
2026-10-24 12:58:40 +08:00

921 lines
37 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 all
// 「真实登记表 → 整机启动图」的判据(2026-10-24)。
//
// ## 迁移记录(ID:13,2026-10-24)
//
// 本文件原先挂在 `internal/kernelboot/modules/registry_test.go`,那个包
// **零生产调用**(死包:Register/BuildModules 是 kernelmod 那套的**第二份
// 实现**,外部调用点 = 0),只为了安放这组判据才留着。
//
// 现迁移到**真实聚合入口** `internal/kernelmod/all`:本包本来就是
// 「把全部模块 import 进来」的那一层,判据放在这里,用的就是生产路径
// 同一条 import 链(all → 各模块 init → kernelmod.Register)。
// 死包与其第二份注册表实现已删除。
//
// ★ 迁移没有削弱任何判据的能力:下面判据 5 的真相源(扫 `..` 源码树的
// 类型大小)与宿主包无关,换宿主前后都照样咬得住「登记了但漏 import」。
// 变异亲测见本文件尾部「失败面」一节第 1–3 条。
//
// ## 这个文件补的是什么(此前完全没有的覆盖)
//
// `internal/kernelboot` 自身的判据很密(图编解码、分层、调度、迁移回滚、
// 并发压测),但**全部用假模块**(`fakeModule`)在图上验算法。
// 也就是说:分层算法被验过,而「**真实的** 17 个模块按真实登记表装配成
// 一台整机、逐层起来、再严格逆序关掉」这件事**一条用例都没有**。
//
// 这中间藏着一整类失败:算法对、单模块对,但**装配起来**不对 ——
// 漏挂一个模块(依赖图缺设施)、某层的关闭函数没登记(漏关)、
// 关闭顺序反了(先关被依赖者,后关依赖者)。
//
// ## 为什么判据必须用真实登记表,而不能造一份"期望清单"
//
// 造假模块:任何"漏挂"都测不出来(假模块之间自洽)。
// 抄一份期望模块名清单:清单在「新增模块但忘了 import」时**也跟着漏**
// ⇒ 判据照绿(本仓已有先例:TestEveryModuleDirIsImported 的初版就栽在
// 手写清单上)。
//
// ⇒ 判据 1–4 用 `kernelmod.Registered()` 作为装配输入,断言的对象是
// **装配结果**(图 + 就绪状态 + 关闭顺序 + 资源回收),而不是某份清单。
//
// ## ★★ 真相源不能是要检测对象的入口(本文件最重要的一条设计约束)
//
// 判据 5(`TestEveryRegisteredModuleIsCompiledIn`)**不能**扫
// `kernelmod.Register(...)` 调用点作为真相源,因为"模块静默消失"有两种形态:
//
// a. 四方法写全、init() 里调了 Register,只差 all/all.go 的聚合 import
// ⇒ Register 调用点仍在源码里 ⇒ 扫调用点**抓得住**
// b. 四方法写全,但 init() 根本没写(或写了又被删)
// ⇒ Register 调用点不存在 ⇒ **要检测的入口自己消失了**
// ⇒ 源码侧与登记表侧同时少一个 ⇒ 集合比对照常相等 ⇒ **假绿**
//
// b 才是"静默消失"最危险的形态:编译通过、四方法齐全、只是没人注册。
// 把「被测对象」同时当「检测入口」,入口一被破坏,判据就**失去视野**——
// 这与本仓此前几次判据假绿同病(真相源与被测对象同源)。
//
// ⇒ 判据 5 的真相源换成与 Register 无关的**接口形状**:
// 「目录 + 类型名」复合键 + 「Name/Requires/Provides/Init 四方法齐全」。
// 模块哪怕不 Register、不被 import,四方法仍写在源码里 ⇒ 一定扫得到。
//
// · 为什么必须带**目录**:本仓大量类型都叫 `Module`,只按类型名做键会撞成
// 一堆(实测撞完只剩 2 个)
// · 为什么必须**四方法集**而不是"有个 Name()":只有 Name 的类型到处都是,
// 四个方法齐才是 `kernelboot.Module` 的形状
// · 为什么取 Name() 的**字符串字面量**:非字面量会让该模块从视野消失,
// 故显式报错**而不静默跳过**(跳过 = 又一次假绿)
//
// ★ 下次谁要改这条判据,先回答:**把它的真相源破坏掉,它还会绿吗?**
// 若会绿,说明真相源选在了被测对象身上,必须换(见上面 b)。
//
// ## 失败面(**逐条实测过**,2026-10-24;不是注释声称)
//
// 记法:⇒ 后面写「谁红 + 红在哪一句」。凡实测与预期不符的,照实写实际形状。
//
// 1. 删掉**有下游依赖**的模块的 `func init() { kernelmod.Register(...) }`
// (实测对象:tracker)
// ⇒ 判据 1/2/3/4 红,红在「真实登记表算不出启动图:依赖了不存在的设施
// [tracker(agent 需要)…]、等待环 [agent plugin-host]」。
// ★ 但**判据 5 也红** —— 见第 2 条,两条互为冗余。
//
// 2. 从 `internal/kernelmod/all/all.go` 去掉任一 `_ ".../kernelmod/xxx"` 行
// ⇒ 实测同上(模块根本没被编译进来)。
//
// 3. ★ 删掉**无下游依赖的叶子模块**的 init()(实测对象:multimodal)
// ⇒ **只有判据 5 红**,判据 1–4 全绿。
// 为什么 1–4 咬不住:没人依赖它 ⇒ 图能解、层顺序对、哨兵数对、无残留。
// 这正是判据 5 存在的唯一理由(初版判据在此**假绿**,见其函数注释)。
//
// 4. 把 Run 的层执行顺序改成逆序
// ⇒ 判据 1/2/3/4 红,但**红在装配失败**(第 4 层模块拿到 nil 依赖:
// 「agent 依赖的设施 “memory” 对应的数据为 nil」),
// 不是「层间顺序断言」咬的 —— 装配先于断言就炸了。
// ⇒ 把某一层整个跳过:**内核 panic**(llm.buildProviderManager 拿到
// 0x0 的 LuaVM,SIGSEGV)。仍是红的,但形态是崩溃不是可读断言。
// ★ 以上是 **ID:14 之前**的形态(2026-10-24 实测记录,保留下来
// 作为“为什么需要执行期校验”的原始证据)。
//
// ✅ **已完成(ID:14,提交 e70c2b5)**:kernelboot.Run 现在在**每层 Init
// 之前**先校“本层依赖 ⊆ 此前真跑完的层实际产出的设施”,缺就返回
// *DependencyNotReadyError(指名模块名 + 缺哪些设施),**本层一个
// Init 都不调**。用真实登记表实测,同一个变异变成可读报错:
// · 逆序 ⇒ 第 **0** 层就拒(produced 为空):
// 「模块 agent 缺设施 [docmem knowledge llm …];
// 模块 plugin-host 缺设施 [config docmem …]」
// · 跳过第 2 层 ⇒ 第 **2** 层拒:
// 「模块 stage-host 缺设施 [knowledge llm textmem]」
// **两种都不再是 panic / SIGSEGV**。
//
// ⇒ 下次看到本条,请不要据此认为“Run 不拦 nil 依赖”;它现在拦。
// 另:该行为的专用回归判据在 internal/kernelmod/all/runtime_dep_test.go
// (A 正序不误报 / B 交换两层 / C 删提供者整层)与
// internal/kernelboot/runtimedep_test.go(纯逻辑四形状 + 防预填自检)——
// 那两组比在本节做整机变异快得多,改代码时优先跑它们。
//
// 5. 某层不再登记关闭函数(模块忘调 AddCloser / 层边界钩子丢了)
// ⇒ 判据 3 红(哨兵数量不符)。与第 7 条同支路,实测由第 7 条覆盖。
//
// 6. 关闭改成与启动同序(正序)
// ⇒ 判据 3 红:实测「有 5 层,但只观测到 0 个…」/「关闭顺序不是严格逆序」。
//
// 7. 关闭函数不真正执行(CloseAll 变 no-op,等价于「漏 Stop/Close」)
// ⇒ 判据 3 红(哨兵 0 个)+ **判据 4 红**(「第 1 轮关闭后仍多 3 个
// goroutine(基线 11,现 14,允许抖动 2)」)。两条各自独立咬住。
//
// ## 为什么这些断言不需要"扫描源码"或"反射读模块内部"
//
// 全部断言都落在**装配产物**上:分层计划(Layers)、syncbus 的就绪顺序、
// 关闭函数的实际执行顺序、进程级 goroutine 数与文件系统状态。
// 源码怎么组织都无所谓 —— 只认"装配起来之后发生了什么"。
import (
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"reflect"
"runtime"
"sort"
"strconv"
"strings"
"testing"
"time"
internalConfig "github.com/JianFeeeee/HologramAgent/internal/config"
"github.com/JianFeeeee/HologramAgent/internal/kernelboot"
"github.com/JianFeeeee/HologramAgent/internal/kernelmod"
// ★ 判据与被测对象现在同属一个包(internal/kernelmod/all):
// 本文件就在聚合 import 的**那一层**里。因此不需要(也不能)再
// 空白 import 自己 —— 只要本包被编译,all.go 的聚合 import 就已生效。
// 这正是迁移的收益之一:判据用的是生产同一条 import 链,而非旁路。
)
// ─────────────────────────── 整机夹具 ───────────────────────────
// machine 是一台**真实装配起来**的内核(全部模块都是 kernelmod 里的真货)。
type machine struct {
t *testing.T
ks *kernelboot.KernelState
bus *kernelboot.SyncBus
// plan 是真实登记表算出的分层计划(启动图)。
plan *kernelboot.Plan
// res 是执行结果(层数 / 峰值并发 / 完成顺序)。
res *kernelboot.RunResult
// dataDir 是本机的数据目录(真实文件系统,不是内存桩)。
dataDir string
// layerClosers 记录"每层边界登记的关闭函数"被调用的层号顺序。
// 这是"严格逆序关闭"的**观测点**:层边界处 AddCloser 一个哨兵,
// 它必然排在该层所有模块的关闭函数**之后**(OnLayerDone 在层内 Init
// 全部完成后才跑),所以哨兵的调用顺序 = 层的关闭顺序。
layerClosers []int
closed bool
}
// layerSentinelFacilityPrefix 用于在断言消息里区分"哨兵"与"真模块"。
const shutdownProbeName = "boot-graph-test/layer-probe"
// bootMachine 用真实登记表装配一台整机。
//
// 参数 httpAddr 走 127.0.0.1:0(内核会把它传给 webui 插件):
// 不占固定端口 ⇒ 并发跑 CI/本地多次不会 "bind: address already in use"
// (实测:写死默认 :8080 时本机起两次就红)。
func bootMachine(t *testing.T) *machine {
t.Helper()
dir := t.TempDir()
// 只建**真实启动会建**的目录(与 cmd/homed 的 ensureDataDirs 对齐)。
for _, d := range []string{
"memory", "memory/text", "memory/documents", "knowledge",
"adapters", "agentfs", "plugins",
} {
if err := os.MkdirAll(filepath.Join(dir, d), 0o755); err != nil {
t.Fatalf("建数据目录: %v", err)
}
}
reg := internalConfig.NewConfigRegistry(filepath.Join(dir, "config.db"))
// ★ 注册表不在这里 t.Cleanup 关闭:本判据会在**同一个测试**里反复启停
// 多轮,t.Cleanup 要到测试结束才跑 ⇒ 每轮都留一个 *sql.DB 的
// connectionOpener goroutine 活着 ⇒ 泄漏判据会被自家夹具污染
// (实测:3 轮后 goroutine 数 2→5,逐轮 +1,全部来自 database/sql)。
// 关闭点放到 shutdown()(与生产的停机序列同处),并由 machine 保证只关一次。
ks := kernelboot.NewKernelState()
ks.CfgReg = reg
ks.DataDir = dir
ks.AgentWorkDir = filepath.Join(dir, "agentfs")
ks.Cfg = reg.ToConfig()
ks.Cfg.Daemon.DataDir = dir
ks.Cfg.Plugin.Dir = filepath.Join(dir, "plugins")
ks.HTTPAddr = "127.0.0.1:0"
mods := kernelmod.Registered()
if len(mods) == 0 {
t.Fatal("★ 真实登记表是空的 —— `internal/kernelmod/all` 的聚合 import 断了")
}
if d := kernelmod.Duplicates(); len(d) > 0 {
t.Fatalf("★ 模块名重复注册: %v", d)
}
plan, err := kernelboot.Build(mods)
if err != nil {
t.Fatalf("★ 真实登记表算不出启动图(漏挂模块 / 缺依赖 / 环): %v", err)
}
bus := kernelboot.NewSyncBus()
// 与生产 RunKernelModules 同序:先 MarkDeclared(让拼错校验有真相源),
// 再 Run(Run 内部还会 MarkDeclared,幂等)。
var declared []string
for _, layer := range plan.Layers {
for _, m := range layer {
declared = append(declared, m.Provides()...)
}
}
bus.MarkDeclared(declared)
m := &machine{t: t, ks: ks, bus: bus, plan: plan, dataDir: dir}
res, err := kernelboot.Run(plan, kernelboot.RunOptions{
State: ks,
Bus: bus,
MaxParallel: 4, // 与生产一致(cmd/homed 的 RunKernelModules)
OnLayerDone: func(idx int, _ []kernelboot.Module) {
// ★ 每层一个关闭哨兵:它登记在**该层所有模块之后**,
// 因此 CloseAll 的 LIFO 语义要求哨兵按层号**严格降序**被调用。
layer := idx
ks.AddCloser(func() { m.layerClosers = append(m.layerClosers, layer) })
},
})
if err != nil {
t.Fatalf("★ 整机装配失败: %v", err)
}
m.res = res
// ★ 停机走**生产路径**(cmd/homed 的 waitForShutdown 顺序),
// 而不是只调 CloseAll:漏关某层往往正是"生产路径少了一步"。
t.Cleanup(func() {
if !m.closed {
m.shutdown()
}
})
return m
}
// shutdown 执行与生产 `waitForShutdown` 一致的停机序列。
func (m *machine) shutdown() {
m.closed = true
if reg := m.ks.PluginRegistry; reg != nil {
reg.StopAll()
}
if trk := m.ks.Tracker; trk != nil {
if err := trk.Stop(); err != nil {
m.t.Logf("tracker.Stop: %v", err)
}
}
if m.ks.CfgReg != nil {
_ = m.ks.CfgReg.Flush()
// 生产里进程随即退出、句柄由内核回收;测试里必须显式关,
// 否则 *sql.DB 的 connectionOpener 会一直活着(见 bootMachine 注释)。
_ = m.ks.CfgReg.Close()
}
if sup := m.ks.Supervisor; sup != nil {
sup.Shutdown()
}
if err := m.ks.CloseAll(); err != nil {
m.t.Logf("CloseAll: %v", err)
}
}
// moduleNames 返回计划里全部模块名(跨层)。
func (m *machine) moduleNames() []string {
var out []string
for _, layer := range m.plan.Layers {
for _, mod := range layer {
out = append(out, mod.Name())
}
}
sort.Strings(out)
return out
}
// ─────────────────────── 判据 1:整机全部模块就位 ───────────────────────
// ★★★ 判据:**真实登记表里的每个模块都进了图,且其设施全部就绪**。
//
// 防的失败方式:「模块建了但没挂上」—— 新建 `internal/kernelmod/foo/mod.go`
// 写了类型和 `init()`,但忘了在 `all/all.go` 加聚合 import,或忘了写
// `init()` 里的 Register。后果不是编译错,而是该模块**静默不参与装配**:
// 它提供的设施永远不就绪,依赖它的插件**永不启动**(且无任何报错)。
//
// ★ 本判据的**能力边界(实测)**:只有「漏挂的模块被别人依赖」时才红。
//
// 漏掉的是**叶子模块**(没人 Requires 它,如 multimodal)时,
// 本判据连同判据 2/3/4 **全部照绿** —— 图照样能解、层照样对、
// 哨兵照样齐、也没东西可残留。
// 那种形态由判据 5(TestEveryRegisteredModuleIsCompiledIn)单独咬住,
// 它的真相源是源码里的类型与方法集,不依赖 Register 调用点。
// ⇒ 别把本判据当成「漏挂」的通用防线,两判据是互补关系,不是冗余。
func TestRealRegistryAssemblesWholeMachine(t *testing.T) {
m := bootMachine(t)
registered := map[string]bool{}
for _, name := range kernelmod.Names() {
registered[name] = true
}
inPlan := map[string]bool{}
for _, name := range m.moduleNames() {
if inPlan[name] {
t.Fatalf("★ 模块 %s 在启动图里出现多次(分层去重失败)", name)
}
inPlan[name] = true
}
// ① 双向集合相等:登记了却没进图 / 进了图却不在登记表,都是缺陷。
var missingInPlan, missingInRegistry []string
for n := range registered {
if !inPlan[n] {
missingInPlan = append(missingInPlan, n)
}
}
for n := range inPlan {
if !registered[n] {
missingInRegistry = append(missingInRegistry, n)
}
}
sort.Strings(missingInPlan)
sort.Strings(missingInRegistry)
if len(missingInPlan) > 0 {
t.Fatalf("★ 已登记但没进启动图的模块: %v —— 模块静默消失,"+
"它提供的设施永不就绪(依赖它的插件会永不启动且无报错)", missingInPlan)
}
if len(missingInRegistry) > 0 {
t.Fatalf("★ 在启动图里但不在登记表的模块: %v", missingInRegistry)
}
// ② 每个模块声明的设施都必须真的「已就绪」(不是"登记过",是"就绪")。
// ★ 这两个状态必须分开断言:MarkDeclared 会把全部 Provides 记成
// known("登记过"),所以只查 Known 的话,一个模块哪怕从没跑过
// 也"存在"——恰恰漏掉本判据要抓的失败。
snap := m.bus.Snapshot()
var neverReady []string
for _, layer := range m.plan.Layers {
for _, mod := range layer {
for _, f := range mod.Provides() {
if !snap[f] {
neverReady = append(neverReady, mod.Name()+"→"+f)
}
}
}
}
sort.Strings(neverReady)
if len(neverReady) > 0 {
t.Fatalf("★ 这些设施的提供者跑过了却没被标成就绪: %v\n"+
" 后果:插件声明依赖它会**永远等不到**(静默不启动)", neverReady)
}
// ③ 全部层都执行完,且内核完成信号已发(未声明依赖的插件的启动信号)。
if m.res.LayerCount != len(m.plan.Layers) {
t.Fatalf("★ 只执行了 %d/%d 层", m.res.LayerCount, len(m.plan.Layers))
}
if !m.bus.KernelComplete() {
t.Fatal("★ 全部层跑完却没有 MarkKernelComplete —— 未声明依赖的插件永不拉起")
}
// ④ 每个模块的 Init 都被调过:就绪顺序里应包含全部 Provides,
// 且不得出现"从未被任何模块声明"的野设施。
readySet := map[string]bool{}
for _, f := range m.bus.ReadyInOrder() {
readySet[f] = true
}
for _, layer := range m.plan.Layers {
for _, mod := range layer {
for _, f := range mod.Provides() {
if !readySet[f] {
t.Errorf("★ 设施 %s(%s)不在 ReadyInOrder 里", f, mod.Name())
}
}
}
}
t.Logf("整机就绪:%d 层 / %d 模块,峰值层内并发 %d,就绪设施 %d 个",
m.res.LayerCount, len(m.moduleNames()), m.res.MaxConcurrent, len(readySet))
}
// ─────────────────── 判据 2:层间顺序严格符合依赖拓扑 ───────────────────
// ★★★ 判据:**层间必须严格有序,层内两两无依赖**。
//
// 防的失败方式:
// - 执行顺序被改(正序→逆序、或某层被提前/推迟)⇒ 消费者先于提供者
// - 分层退化成"一层装完"(并行度归零,等价于回到串行 17 步)
// - 有人把模块塞进错误的层(图被手改 / 层号漂移)
func TestLayerTopologyIsRespected(t *testing.T) {
m := bootMachine(t)
// ① 逐层检查:本层每个模块的 Requires 只能由**更早的层**提供。
// 这就是 BFS 分层的定义(第 k 层的前驱全在 <k 层)。
providedByLayer := map[string]int{} // 设施 -> 首次被提供的层号
var violations []string
for li, layer := range m.plan.Layers {
// 先检查(用"更早层"的累积结果),再登记本层提供的设施。
for _, mod := range layer {
for _, req := range mod.Requires() {
got, ok := providedByLayer[req]
if !ok {
violations = append(violations,
mod.Name()+" 依赖 "+req+",但它的提供者在**更晚**的层(或不存在)")
continue
}
if got >= li {
violations = append(violations,
mod.Name()+"(第"+itoa(li)+"层)依赖 "+req+
"(第"+itoa(got)+"层)—— 层间顺序必须严格")
}
}
}
for _, mod := range layer {
for _, f := range mod.Provides() {
if _, seen := providedByLayer[f]; !seen {
providedByLayer[f] = li
}
}
}
}
if len(violations) > 0 {
sort.Strings(violations)
t.Fatalf("★ 层间顺序错了:\n %s", strings.Join(violations, "\n "))
}
// ② 层内两两无依赖(否则它们不该同层:其中一个必然在更晚的层)。
// 这是"同层可并行的前提",算法若坏了这条会先红。
for li, layer := range m.plan.Layers {
own := map[string]bool{}
for _, mod := range layer {
for _, f := range mod.Provides() {
own[f] = true
}
}
for _, mod := range layer {
for _, req := range mod.Requires() {
if own[req] {
t.Fatalf("★ 第 %d 层内部存在依赖:%s 依赖同层的 %s —— "+
"同层并行会读到未初始化的依赖", li, mod.Name(), req)
}
}
}
}
// ③ 观测到的就绪顺序必须与分层一致:某设施被标记就绪时,
// 它所在层的**全部前驱层**都已就绪。用 ReadyInOrder(真实写入顺序)
// 而不是 Layers(计划),这样才能咬住"执行时乱序"。
readyAt := map[string]int{}
for i, f := range m.bus.ReadyInOrder() {
readyAt[f] = i
}
for li, layer := range m.plan.Layers {
for _, mod := range layer {
for _, req := range mod.Requires() {
reqLayer := providedByLayer[req]
if reqLayer >= li {
continue // 已在 ① 报过
}
// 前驱设施必须先于本模块任一设施进入就绪序列。
for _, f := range mod.Provides() {
if readyAt[req] > readyAt[f] {
t.Fatalf("★ 就绪顺序反了:%s(%s) 先于其依赖 %s 就绪",
f, mod.Name(), req)
}
}
}
}
}
// ④ 分层必须真的带来并行(否则"图"退化成一条串行链,等于没做)。
// ★ 不写死层数/模块数:真实登记表增删模块时这条仍然成立。
if len(m.plan.Layers) >= len(m.moduleNames()) {
t.Fatalf("★ 无并行度:%d 层 / %d 模块(等价于全串行)",
len(m.plan.Layers), len(m.moduleNames()))
}
if m.res.MaxConcurrent < 2 {
t.Fatalf("★ 峰值层内并发只有 %d —— 同层并行没生效", m.res.MaxConcurrent)
}
}
// ─────────────────── 判据 3:关闭严格逆序 ───────────────────
// ★★★ 判据:**关闭顺序 = 启动顺序的严格逆序(按层)**。
//
// 防的失败方式:
// - 某层漏关(模块忘 AddCloser / 层边界钩子被删)⇒ 哨兵少一个
// - 关闭顺序改成与启动同序(正序)⇒ 先关被依赖者,依赖者随后用到已关资源
// - CloseAll 的 LIFO 语义被改(例如排序后执行)
func TestShutdownIsStrictlyReverseLayerOrder(t *testing.T) {
m := bootMachine(t)
layers := len(m.plan.Layers)
// 先停机再清点:哨兵是在 CloseAll 执行时才被调用的,装配刚完成时它
// 只是"登记了"。
m.shutdown()
got := m.layerClosers
if len(got) != layers {
t.Fatalf("★ 有 %d 层,但只观测到 %d 个层边界关闭哨兵被调用(顺序 %v)—— "+
"说明有层在关闭时被漏掉了(该层的模块资源不会释放)",
layers, len(got), got)
}
want := make([]int, layers)
for i := 0; i < layers; i++ {
want[i] = layers - 1 - i // 严格降序 = 启动顺序的逆序
}
if !equalIntSlice(got, want) {
t.Fatalf("★ 关闭顺序不是严格逆序:\n 实际(关闭顺序)= %v\n"+
" 期望(启动的逆序)= %v\n"+
" 正序关闭 ⇒ 先关被依赖者,依赖它的模块随后用到已关的资源", got, want)
}
// 关闭后 Closers 必须被消费干净(否则下次 CloseAll 会重跑已关资源)。
if n := len(m.ks.Closers); n != 0 {
t.Fatalf("★ CloseAll 之后仍残留 %d 个关闭函数(重复执行会二次释放)", n)
}
t.Logf("关闭顺序(层号)= %v —— 严格逆序 ✓", got)
}
// ─────────────────── 判据 4:关闭后无残留 ───────────────────
// goroutineSlack 是允许的 goroutine 抖动上限。
//
// ★ 为什么是 2 而不是 0:
// `testing` 自身在用例执行期间会有短暂 goroutine(如 -test.timeout 的
// 定时器、t.Cleanup 的调度),且 Go 运行时的 GC worker 会在
// runtime.GC() 后短暂存活。实测(本机 + CI 同构)关闭后
// 差值恒为 0,留 2 的余量只为吸收这种框架级抖动 —— 不是用来掩盖泄漏的
// (泄漏的量级是"每层一个轮询循环",即 ≥5,远超 2)。
const goroutineSlack = 2
// ★★★ 判据:**关闭后进程回到基线:无 goroutine 泄漏、无资源残留**。
//
// 防的失败方式:
// - 某模块的停止函数没跑(轮询/回调循环继续跑)⇒ goroutine 不回落
// - 句柄没关(DB/文件/HTTP server)⇒ 临时目录删不掉
// - 停机路径**越关越多**(关闭时又注册订阅/起协程)⇒ 反复启停会累积
//
// 做法:连续启停整机 3 轮,每轮都要求回落到基线,且差值不随轮次增长。
// 单轮回落可能是"碰巧",多轮不增长才能排除"每轮漏一点"。
func TestShutdownLeavesNoResidue(t *testing.T) {
baseline := settleGoroutines(t, 1<<30) // 先让运行时安静下来
t.Logf("基线 goroutine 数 = %d", baseline)
const rounds = 3
prevAfter := baseline
for round := 0; round < rounds; round++ {
m := bootMachine(t)
peak := runtime.NumGoroutine()
subscribersAfterBoot := countBusSubscribers(t, m.bus)
dir := m.dataDir
m.shutdown()
// ① goroutine 回落到基线(有界重试,不写死 sleep)。
after := settleGoroutines(t, baseline+goroutineSlack)
delta := after - baseline
if delta > goroutineSlack {
t.Fatalf("★ 第 %d 轮关闭后仍多 %d 个 goroutine(基线 %d,现 %d,允许抖动 %d)\n"+
" 这通常意味着某层模块的停止函数没跑(轮询/回调循环还活着)\n%s",
round+1, delta, baseline, after, goroutineSlack, goroutineStacks())
}
// ② 绝对水位不得逐轮抬升(排除"每轮漏一点"的慢泄漏)。
//
// ★ 比的是**绝对 goroutine 数**而不是相对第一轮基线的差值:
// 测试框架自身(-test.timeout 定时器、t.Cleanup 调度、GC worker)
// 会漂 1 个,用差值会在第 2 轮把框架抖动误报成泄漏。
// 真泄漏的量级是"每层一个轮询循环"(≥5/轮),逐轮抬升必然可见。
if round > 0 && after > prevAfter {
t.Fatalf("★ 关闭后的 goroutine 水位在抬升:第 %d 轮 %d > 上一轮 %d —— "+
"反复启停会累积泄漏\n%s", round+1, after, prevAfter, goroutineStacks())
}
prevAfter = after
// ③ 临时目录必须删得掉:有未关闭的句柄/未卸载的 overlay 时会 EBUSY。
// 这是"漏释放资源"最直接的外部证据(比看代码可靠)。
if err := removeAllWithRetry(dir, 20); err != nil {
var left []string
_ = filepath.Walk(dir, func(p string, fi os.FileInfo, err error) error {
if err == nil && fi != nil {
rel, _ := filepath.Rel(dir, p)
left = append(left, rel)
}
return nil
})
sort.Strings(left)
t.Fatalf("★ 关闭后临时目录删不掉(有句柄/挂载未释放): %v\n 残留项: %v",
err, left)
}
// ④ syncbus 订阅者不得在关闭路径上**增长**。
//
// ★ 为什么断言"不增长"而不是"归零":`SyncBus` 刻意**没有**
// Unsubscribe(它是启动期一次性状态快照,随本次装配的 bus 一起
// 被丢弃)。所以绝对归零需要给内核加 API —— 那是业务改动,
// 不在本判据的权限内。真正的泄漏形态是"关闭时又挂上了订阅"
// (每轮+1),这条能咬住;绝对归零作为已知缺口单独报出。
subsAfterShutdown := countBusSubscribers(t, m.bus)
if subsAfterShutdown > subscribersAfterBoot {
t.Fatalf("★ 关闭路径**新增**了 syncbus 订阅者:装配后 %d → 关闭后 %d "+
"—— 关闭时不该再挂回调(会持有已停止的对象)",
subscribersAfterBoot, subsAfterShutdown)
}
t.Logf("第 %d 轮:基线 %d,装配峰值 %d,关闭后 %d(差 %d),"+
"订阅者 %d→%d,临时目录已清理",
round+1, baseline, peak, after, delta,
subscribersAfterBoot, subsAfterShutdown)
}
}
// ──────────── 判据 5:登记表必须覆盖源码树(独立真相源) ────────────
// ★★★ 判据:**源码树里每个「实现了 kernelboot.Module 四方法」的类型,
// 其 Name() 必须出现在登记表里**。
//
// ## 真相源为什么必须是「接口形状」而不是 Register 调用点(初版的教训)
//
// 「漏挂一个模块」有两种形态,初版只抓得住第二种:
//
// a. 写好了 mod.go(类型 + init()),但 all/all.go 少一行聚合 import
// ⇒ Register 调用点**仍在源码里** ⇒ 扫得到 ⇒ 抓得住
// b. 连 init() 都没写 / 写了又删了(其余三方法齐全)
// ⇒ Register 调用点不存在 ⇒ 初版扫描的入口本身消失
// ⇒ 源码侧与登记表侧**同时少一个** ⇒ 集合比对照常相等 ⇒ 假绿
//
// b 正是「模块静默消失」最危险的形态:四方法写全了、编译也过、
// 只是没人调 Register。把「要检测的对象」同时当「检测入口」,
// 入口一被破坏判据就失去视野 —— 与 ID:16/20/23 记的三次判据假绿同病:
// **真相源与被测对象同源**。
//
// ⇒ 换成与 Register 无关的真相源:**类型 + 方法集**。
// 模块哪怕不 Register、不被 import,Name/Requires/Provides/Init 四个方法
// 仍写在源码里(AST 按「目录+类型名」复合键收集,四方法齐全即视为模块,
// 取其 Name() 的字符串字面量)。
//
// ## 已排除的误报面(设计时逐个实测)
//
// · 类型重名:本仓大量类型都叫 `Module`,**只按类型名做键会撞成一堆**
// (实测撞成只剩 2 个)⇒ 必须用 目录+类型名 复合键
// · 指针接收者:agent 的方法是 `func (*Module) Name()`,
// AST 要同时处理 *ast.Ident 与 *ast.StarExpr
// · 非字面量 Name():**显式报错**,不静默跳过(否则该模块从视野消失)
// · `_test.go` / `all/`(聚合 import 包)/ `testdata/`:不扫
//
// 实测基线:本仓该法恰扫出 17 个类型,与登记表 17 个名字一一对应(零误报)。
//
// 失败面(**已实测咬红**,不是注释声称):
//
// · 删任一模块的 init()(含无下游依赖的叶子模块 multimodal)⇒ 红
// · 删 all/all.go 任一聚合 import ⇒ 红(该类型四方法仍在 ⇒ 源码侧多一个)
// · 删掉整个模块目录 ⇒ 反向断言红(登记表里有、源码树里没有)
func TestEveryRegisteredModuleIsCompiledIn(t *testing.T) {
const modRoot = ".."
fromSource := scanModuleTypes(t, modRoot)
if len(fromSource) == 0 {
t.Fatal("★ 源码扫描一个模块都没找到 —— 扫描逻辑失效(判据会永远绿)")
}
registered := map[string]bool{}
for _, n := range kernelmod.Names() {
registered[n] = true
}
var missing []string
for name, file := range fromSource {
if !registered[name] {
rel, _ := filepath.Rel(modRoot, file)
missing = append(missing, name+"("+rel+")")
}
}
sort.Strings(missing)
if len(missing) > 0 {
t.Fatalf("★ 源码树里有 %d 个「四方法齐全的模块类型」**没被登记**(模块静默消失):\n %s\n"+
" 修复:给该类型补 `func init() { kernelmod.Register(X{}) }`,"+
"并在 internal/kernelmod/all/all.go 加聚合 import\n"+
" 后果:它提供的设施永不就绪 ⇒ 依赖它的插件永不启动,且无任何报错",
len(missing), strings.Join(missing, "\n "))
}
// 反向:登记表里的每个模块名都应能在源码树里找到(防夹具污染生产表)。
var phantom []string
for name := range registered {
if _, ok := fromSource[name]; !ok {
phantom = append(phantom, name)
}
}
sort.Strings(phantom)
if len(phantom) > 0 {
t.Fatalf("★ 登记表里有 %v,但源码树里找不到四方法齐全的类型 —— "+
"登记项可能是测试夹具或残留", phantom)
}
t.Logf("源码树扫描到 %d 个模块类型,登记表 %d 个,双向覆盖 ✓",
len(fromSource), len(registered))
}
// scanModuleTypes 按「目录+类型名」复合键收集模块类型(独立真相源)。
//
// 判定:Name/Requires/Provides/Init 四方法齐全 ⇒ 是模块类型;
// 取 Name() 的字符串字面量作为模块名。返回 模块名 → 定义文件路径。
func scanModuleTypes(t *testing.T, root string) map[string]string {
t.Helper()
fset := token.NewFileSet()
type key struct{ dir, typ string }
methods := map[key]map[string]bool{}
nameLit := map[key]string{}
nameFile := map[key]string{}
err := filepath.Walk(root, func(path string, fi os.FileInfo, err error) error {
if err != nil || fi == nil {
return nil
}
if fi.IsDir() {
switch fi.Name() {
case "all", "testdata":
// all 是聚合 import 包(自身不定义模块类型);testdata 不被编译。
return filepath.SkipDir
}
return nil
}
if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
return nil
}
f, perr := parser.ParseFile(fset, path, nil, 0)
if perr != nil {
return nil // 语法错误交给编译器报,这里不当判据失败
}
dir := filepath.Dir(path)
for _, d := range f.Decls {
fn, ok := d.(*ast.FuncDecl)
if !ok || fn.Recv == nil || len(fn.Recv.List) == 0 {
continue
}
var typ string
switch rt := fn.Recv.List[0].Type.(type) {
case *ast.Ident:
typ = rt.Name
case *ast.StarExpr:
if id, ok := rt.X.(*ast.Ident); ok {
typ = id.Name // 指针接收者(如 agent 的 *Module)
}
}
if typ == "" {
continue
}
k := key{dir, typ}
if methods[k] == nil {
methods[k] = map[string]bool{}
}
methods[k][fn.Name.Name] = true
if fn.Name.Name == "Name" && fn.Body != nil {
for _, st := range fn.Body.List {
ret, ok := st.(*ast.ReturnStmt)
if !ok || len(ret.Results) != 1 {
continue
}
lit, ok := ret.Results[0].(*ast.BasicLit)
if !ok || lit.Kind != token.STRING {
continue
}
v, uerr := strconv.Unquote(lit.Value)
if uerr != nil {
continue
}
nameLit[k] = v
nameFile[k] = path
}
}
}
return nil
})
if err != nil {
t.Fatalf("扫描源码树: %v", err)
}
out := map[string]string{}
for k, ms := range methods {
complete := true
for _, want := range []string{"Name", "Requires", "Provides", "Init"} {
if !ms[want] {
complete = false
break
}
}
if !complete {
continue
}
name, ok := nameLit[k]
if !ok {
// ★ 显式失败而不是跳过:非字面量 Name() 会让该模块从判据视野消失,
// 那就又是一次静默漏测(初版判据就是这么假绿的)。
t.Fatalf("★ %s 里类型 %s 四方法齐全但 Name() 不是字符串字面量 —— "+
"本判据无法静态解析它。请把 Name() 改成 `return \"...\"`,"+
"或在 scanModuleTypes 里补上解析(**不要**静默跳过)",
nameFile[k], k.typ)
}
out[name] = nameFile[k]
}
return out
}
// ─────────────────────────── 辅助 ───────────────────────────
// settleGoroutines 让 goroutine 数回落到 <= target,返回最终观测值。
//
// 有界重试(不是写死 sleep):每轮 GC + 短暂让出,最多 100 轮(约 2s)。
// 轮次上限的存在让"真泄漏"以超时后的真实数字报出来,而不是永远等下去。
func settleGoroutines(t *testing.T, target int) int {
t.Helper()
var n int
for i := 0; i < 100; i++ {
runtime.GC()
n = runtime.NumGoroutine()
if n <= target {
return n
}
time.Sleep(20 * time.Millisecond)
}
return n
}
// removeAllWithRetry 反复尝试删目录(有界)。
//
// 为何需要重试:关闭函数的先后顺序里,DB 的 Close 与文件句柄的释放
// 可能比内核的 dentry 回收稍晚;Linux 上偶发 EBUSY。重试上限保证
// "真删不掉"不会变成无限等待。
func removeAllWithRetry(dir string, tries int) error {
var err error
for i := 0; i < tries; i++ {
if err = os.RemoveAll(dir); err == nil {
return nil
}
time.Sleep(50 * time.Millisecond)
}
return err
}
// countBusSubscribers 读 syncbus 上的订阅者数量。
//
// 只读反射(不 Set、不 Interface),用于把"关闭时挂订阅"这种泄漏
// 变成可断言的数字。字段名/类型变化时本函数会 fatalf —— 那是判据
// 失去观测点,必须显式修,不能静默变成 0。
func countBusSubscribers(t *testing.T, bus *kernelboot.SyncBus) int {
t.Helper()
if bus == nil {
return 0
}
f := reflect.ValueOf(bus).Elem().FieldByName("onUpdate")
if !f.IsValid() || f.Kind() != reflect.Slice {
t.Fatalf("syncbus 的订阅者字段不再是 slice(判据失去观测点)")
}
return f.Len()
}
func equalIntSlice(a, b []int) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func itoa(n int) string {
if n == 0 {
return "0"
}
var buf [20]byte
i := len(buf)
neg := n < 0
if neg {
n = -n
}
for n > 0 {
i--
buf[i] = byte('0' + n%10)
n /= 10
}
if neg {
i--
buf[i] = '-'
}
return string(buf[i:])
}
// goroutineStacks 在泄漏时抓全量栈(带进失败消息,便于定位是哪一层没关)。
func goroutineStacks() string {
buf := make([]byte, 1<<20)
n := runtime.Stack(buf, true)
return "--- goroutine 栈 ---\n" + string(buf[:n])
}