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。
This commit is contained in:
JianFeeeee
2026-10-24 11:30:51 +08:00
parent f408448c37
commit ed8f28ea6c
5 changed files with 1434 additions and 15 deletions

180
.github/scripts/check-ci-gates.py vendored Executable file
View File

@ -0,0 +1,180 @@
#!/usr/bin/env python3
"""CI 门禁自检:断言 ci.yml 的关键机制仍在。退出码 0 = 通过。
## 为什么用 YAML 解析,而不是 grep 文本
本次改动的注释里**刻意保留了旧写法的引述**(例如
「原先这里写死 ./internal/agent/core/ ./cmd/waiter/ 两个包」)。
于是 grep 会两头骗人:
· 断言「旧写法不存在」→ 注释里那句引述让它**假红**
· 断言「新机制存在」 → 注释里写一句同名机制就能让它**假绿**
yaml.safe_load 丢掉全部注释,只留可执行内容 ⇒ 两类误判同时消失。
## 这条判据防的是什么
CI 配置是「静默退化」的高发区:把动态枚举改回写死包列表,
流水线照样绿(只是罩得少了),没人会发现。所以必须有人盯着
「动态枚举还在」「go test 真的吃了枚举结果」这两件事。
"""
import sys
import yaml
PATH = ".github/workflows/ci.yml"
fails = []
def check(cond, msg):
if not cond:
fails.append(msg)
with open(PATH, encoding="utf-8") as f:
doc = yaml.safe_load(f)
jobs = (doc or {}).get("jobs") or {}
check(bool(jobs), "YAML 解析后没有 jobs —— 文件结构坏了")
LEGACY_RACE_PKGS = "./internal/agent/core/ ./cmd/waiter/"
def code_lines(run):
"""只保留**可执行**行:剥掉 shell 注释。
★ 为什么必须这么干(本条判据自己栽过的坑):
YAML 解析只丢掉了 YAML 级注释,但 `run: |` 块里的 `# ...`
是**字符串内容**,照样落在 run 文本里。
于是 `"cmd/gui" in run` 会被一句说明性注释满足 ——
真把 `grep -v '^.*/cmd/gui$'` 删掉,断言仍然绿。
判据自己被注释骗过去,和被测代码骗判据是同一类错误。
"""
out = []
for line in run.splitlines():
if line.lstrip().startswith("#"):
continue
out.append(line)
return "\n".join(out)
def step_runs(job):
out = []
for st in (jobs.get(job) or {}).get("steps") or []:
run = st.get("run")
if run:
out.append((st.get("name") or "", run))
return out
# ── 1. race job:包列表必须动态枚举,并真的剔除 cmd/gui ──
race = step_runs("race")
enum = [(n, r) for n, r in race if "go list ./..." in r and "GITHUB_OUTPUT" in r]
check(len(enum) == 1, "race job 应恰有一步用 `go list ./...` 动态枚举包(实际 %d 步)" % len(enum))
if enum:
# ★ 用 code_lines 剥掉 shell 注释后再断言,否则注释里的 "cmd/gui" 会顶包
check("cmd/gui" in code_lines(enum[0][1]),
"race 枚举步骤必须显式剔除 cmd/gui(否则 go test 报 no Go files 的假红)")
# ── 2. 真正的 go test -race 必须吃枚举结果,不许写死包列表 ──
#
# ★ 只认「全仓主步骤」:race job 里还有一步是**单包取证**(只跑 webui 那一条
# 已知竞争),它本来就不该吃全仓枚举。初版脚本没区分这两类,把取证步骤
# 也套上「必须吃枚举」的断言 ⇒ 误报红。判据自己误报同样是缺陷(会让人
# 习惯性忽略红),所以这里按「步骤是否消费 PKGS 变量」来分流。
main_race = [(n, r) for n, r in race
if "go test -race" in r and "$PKGS" in r]
check(len(main_race) == 1,
"race job 应恰有一步全仓 go test -race 消费 $PKGS(实际 %d 步)" % len(main_race))
for _n, r in main_race:
check("steps.pkgs.outputs.pkgs" in code_lines(r),
"全仓 go test -race 必须使用枚举结果 steps.pkgs.outputs.pkgs")
check(LEGACY_RACE_PKGS not in code_lines(r),
"全仓 go test -race 里出现了旧的写死包列表 " + LEGACY_RACE_PKGS)
# 取证步骤:允许单包跑,但必须写明修复单号(否则排除项成了无主的掩盖)
evid = [(n, r) for n, r in race if "go test -race" in r and "$PKGS" not in r]
for _n, r in evid:
check("DATA RACE" in r,
"取证步骤应显式检查 DATA RACE 报告数(否则取证没意义)")
# ── 3. skip 排除表:每项必须写原因,无原因必须步骤自己失败 ──
check(any("没有写原因" in r for _n, r in race),
"race job 缺少 -skip 排除表的结构校验(无原因的排除必须让步骤失败)")
# ── 4. go job:整机启动图判据必须单列一步跑 kernelboot/modules ──
gojob = step_runs("go")
boot = [r for _n, r in gojob if "kernelboot/modules" in r and "go test" in r]
check(bool(boot), "go job 缺少「整机启动图判据」步骤(go test ./internal/kernelboot/modules/)")
for r in boot:
check("-count=1" in r, "整机启动图判据步骤必须带 -count=1(不许吃 go test 缓存)")
# ── 5. cross job:cmd 清单必须实测枚举,不许写死 ──
cross = step_runs("cross")
pure = [r for _n, r in cross if "CGO_ENABLED=0 go build" in r and "GITHUB_OUTPUT" in r]
check(bool(pure), "cross job 缺少「枚举可纯交叉编译的 cmd」步骤")
builds = [(n, r) for n, r in cross if "go build -trimpath" in r]
check(bool(builds), "cross job 里没有真正的 go build 步骤")
for _n, r in builds:
check("steps.pure.outputs.cmds" in r,
"交叉构建必须吃枚举结果 steps.pure.outputs.cmds,不许写死 cmd 列表")
# ── 5.5 code_lines 自测:这是「注释顶包」的唯一防线,必须自己也被验 ──
#
# ★ 为什么需要这段自测(M7 的教训):
# 原先把「code_lines 不再剥注释」当成一条变异,但它**单独注入时不会红** ——
# 因为 ci.yml 完好时,`grep -v cmd/gui` 真实存在,剥不剥注释都能匹配上。
# 那是**空变异**(行为没变,不可能红),拿它当判据缺口是误判。
# 正确做法不是造更狠的变异,而是让 code_lines 的行为**自己可被验证**:
# 喂一个"cmd/gui 只出现在注释里"的样本,要求剥离后断言为假。
# 这样把 code_lines 改成不剥注释,本条立刻失败 —— 单文件、真行为变化。
_probe = "#!/bin/bash\n# cmd/gui 只是注释里的说明\necho hi\n"
if "cmd/gui" in code_lines(_probe):
fails.append("code_lines 没剥掉 shell 注释 —— cmd/gui 断言会被注释顶包(假绿)")
_keep = "#!/bin/bash\nALL=$(go list ./... | grep -v '^.*/cmd/gui$')\n"
if "cmd/gui" not in code_lines(_keep):
fails.append("code_lines 把可执行行也删了 —— 断言会假红")
# ── 6. pkg 级排除(整包不进 -race):必须带修复单号 + 兜底断言 ──
#
# 整包排除是有代价的:该包所有用例都不再受竞争检测保护。
# 因此要求:① 排除表每项必须有修复单号;② 剔除后包数有下限兜底,
# 不许"排除逻辑写坏了导致静默少测";③ 断言排除项确实从待测列表消失。
allrace = "\n".join(r for _n, r in race)
if "KERNEL-REGISTRY-SETLOCK" in allrace or "pkg" in allrace:
check("修复单" in allrace or "修复单号" in allrace,
"pkg 级排除必须写明修复单号(否则成了无主的静默掩盖)")
check("-lt 50" in allrace or "lt 50" in allrace,
"剔除后必须有包数下限兜底(防止排除逻辑写坏导致静默少测)")
check("仍出现在待测列表里" in allrace or "剔除逻辑失效" in allrace,
"必须断言被排除的包确实从列表消失(防止写了排除但没生效)")
# ── 7. 每个 job 的每个 run 步骤:bash 语法必须过 ──
import os
import re
import subprocess
import tempfile
for jn, job in jobs.items():
for st in job.get("steps") or []:
run = st.get("run")
if not run:
continue
body = re.sub(r"\$\{\{[^}]*\}\}", "PLACEHOLDER", run)
fd, p = tempfile.mkstemp(suffix=".sh")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(body)
r = subprocess.run(["bash", "-n", p], capture_output=True, text=True)
if r.returncode != 0:
first = (r.stderr or "").strip().splitlines()
fails.append("shell 语法错误 @ %s / %s: %s"
% (jn, st.get("name"), first[0] if first else "?"))
finally:
os.unlink(p)
if fails:
print("CI 门禁自检失败:")
for f in fails:
print(" x " + f)
sys.exit(1)
print("CI 门禁自检通过:动态枚举 / 排除表校验 / 启动图判据步骤 / 交叉枚举 / 全部 run 步骤语法")