mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 06:47:29 +00:00
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:
180
.github/scripts/check-ci-gates.py
vendored
Executable file
180
.github/scripts/check-ci-gates.py
vendored
Executable 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 步骤语法")
|
||||
338
.github/workflows/ci.yml
vendored
338
.github/workflows/ci.yml
vendored
@ -67,34 +67,307 @@ jobs:
|
||||
- name: go test ./...
|
||||
run: go test ./... -count=1 -timeout 20m
|
||||
|
||||
# 整机启动图判据(internal/kernelboot/modules/registry_test.go)。
|
||||
#
|
||||
# ## 为什么单列一步(而不是只靠上面的 go test ./...)
|
||||
#
|
||||
# 这组判据用**真实模块登记表**装配整机(17 个真模块,全部真 Init),
|
||||
# 会真的建 SQLite、起 HTTP server、mount overlay、起蒸馏/轮询循环。
|
||||
# 把它单列出来有两个作用:
|
||||
# ① 失败时一眼看到是"整机装配"这一层坏了,而不是混在 93 个包的输出里
|
||||
# ② 它断言的是**跨模块的顺序与资源回收**,是"多跑几次才暴露"的那类,
|
||||
# 单独一步便于在 flaky 时被识别(这里带 -count=1,不吃缓存)
|
||||
#
|
||||
# ★ 不用 -race(那在 race job 里跑):本判据含真实端口/挂载,
|
||||
# -race 的插桩会让时序假设变松,两者分开放更便于定位。
|
||||
- name: 整机启动图判据(真实登记表装配 + 逆序关闭 + 无残留)
|
||||
run: |
|
||||
go test ./internal/kernelboot/modules/ \
|
||||
-count=1 -timeout 10m -v
|
||||
|
||||
# 跨平台客户端版本一致性:内核 internal/meta 是唯一事实源,
|
||||
# GUI(package.json) / 鸿蒙(AppScope/app.json5) / waiter 都必须跟它一致。
|
||||
- name: 客户端版本一致性
|
||||
run: make check-client-versions
|
||||
|
||||
# ── 竞态检测(并发改动的主要防线)──
|
||||
#
|
||||
# ## 为什么包列表是**动态枚举**而不是写死
|
||||
#
|
||||
# 原先这里写死 `./internal/agent/core/ ./cmd/waiter/` 两个包 ——
|
||||
# kernelboot 下 scheduler / syncbus / stress 的并发用例因此**全在裸奔**。
|
||||
# 写死列表的失败模式是**静默的**:新增包不会被覆盖,而 CI 一直是绿的,
|
||||
# 没人会发现门禁没罩到。
|
||||
#
|
||||
# ⇒ 改成 `go list ./...` 全仓枚举(93 包 → 剔除 cmd/gui 后 92 个,
|
||||
# 实测 -race 全绿耗时约 72s)。排除必须写理由,见下面的 grep -v。
|
||||
#
|
||||
# ## 唯一被 -skip 的用例:webui 的真数据竞争(**待修业务代码**)
|
||||
#
|
||||
# TestOpenAIStreamIsActuallyStreaming 在 -race 下**稳定报真竞争**(实测
|
||||
# 6/6 次触发,每次 6~8 条 DATA RACE 报告):
|
||||
#
|
||||
# Read gzipResponseWriter.Write gzip.go:139 (读 g.mode)
|
||||
# └─ 调用方:streamOpenAI 的 handler goroutine handler_openai.go:256
|
||||
# Write gzipResponseWriter.commit gzip.go:206 (写 g.mode/g.wroteHeader)
|
||||
# └─ 调用方:streamOpenAI **写 goroutine**(16ms 批量 flush)handler_openai.go:165
|
||||
#
|
||||
# 根因:`gzipResponseWriter` 的 mode/wroteHeader/buf 无锁,而
|
||||
# handler goroutine(发 finish chunk/[DONE])与写 goroutine(发增量)
|
||||
# **并发写同一个 http.ResponseWriter**。这是被测逻辑的真缺陷。
|
||||
#
|
||||
# ★ 本次任务授权是"只动 CI 与测试",**不改业务逻辑** ⇒ 只能用 -skip 挡住,
|
||||
# 并在报告里带原始栈交回。修好后**必须**删掉这里与下一步的名字。
|
||||
# ★ 为什么不选择"让它红着":长红 ⇒ 所有人学会忽略 ⇒ 整条 race 门失效,
|
||||
# 连带把 kernelboot 等**新纳入**的覆盖一起废掉。
|
||||
#
|
||||
# ## 另有一个偶发失败(**没有** skip,照实记录)
|
||||
#
|
||||
# internal/agent/core: TestStress_NestingReachesStructuralBoundThenUnwindsLIFO
|
||||
# · 本 job 跑整个包时:实测 13/13 全绿(测试之间互相预热)
|
||||
# · **单独**用 -run 指它 + -race 时:5/12 失败
|
||||
# ("第 N 级注入后栈深未达 N:0")
|
||||
# · 不带 -race 单独跑:0/12 失败
|
||||
# ⇒ 它是**用例自身的时序假设**(要求 300ms 窗口内恰好把第 N 面压进栈)
|
||||
# 被 -race 插桩放大后错过,**不是数据竞争**(无 DATA RACE 报告)。
|
||||
# 故**不 skip**(skip 会掩盖该用例的回归),但把结论与复现命令记在这里:
|
||||
# go test -race ./internal/agent/core/ \
|
||||
# -run TestStress_NestingReachesStructuralBoundThenUnwindsLIFO -count=1
|
||||
race:
|
||||
name: Race detector
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
- name: go test -race(并发核心)
|
||||
- name: 枚举 -race 覆盖的包
|
||||
id: pkgs
|
||||
run: |
|
||||
go test -race \
|
||||
./internal/agent/core/ ./cmd/waiter/ \
|
||||
-count=1 -timeout 15m
|
||||
set -euo pipefail
|
||||
# cmd/gui 是纯 Electron 目录(只有 doc.go,无测试),
|
||||
# 显式点名它会让 go test 报 "no Go files" 的假红。
|
||||
ALL=$(go list ./... | grep -v '^.*/cmd/gui$' | tr '\n' ' ')
|
||||
N=$(echo $ALL | wc -w)
|
||||
echo " 将用 -race 覆盖 $N 个包"
|
||||
# 兜底:枚举失败(0 个包)必须响亮失败,不能静默跳过整道门。
|
||||
if [ "$N" -lt 50 ]; then
|
||||
echo "::error::只枚举出 $N 个包,枚举逻辑可能失效"
|
||||
exit 1
|
||||
fi
|
||||
echo "pkgs=$ALL" >> "$GITHUB_OUTPUT"
|
||||
# ── 排除表(**唯一**允许从 -race 门里拿掉的东西,必须带原因 + 修复单号)──
|
||||
#
|
||||
# ## 为什么做成"表 + 每次打印",而不是把名字内联在 go test 命令里
|
||||
#
|
||||
# 内联的 `-skip 'X|Y'` 有三个问题:
|
||||
# ① 没人能一眼看出**当前一共排除了几项、分别为什么、归谁修**
|
||||
# ② 修好之后没人会记得回来删 ⇒ 它静默地一直挡着,那个用例从此
|
||||
# 永不执行,而 CI 一直绿(正是"静默覆盖丢失")
|
||||
# ③ 新增排除时容易顺手加个名字、不写理由,排除表就腐烂了
|
||||
#
|
||||
# 做成表后:每次 CI 都**打印**完整清单(项数 + 类型 + 名字 + 原因 + 单号),
|
||||
# 并做结构校验:**缺 reason 或缺单号的条目直接让这一步失败**。
|
||||
#
|
||||
# ## 两种粒度,必须区分(混用会造成覆盖静默丢失)
|
||||
#
|
||||
# pkg 级 —— 该包整体不参与 -race(竞争在**内核装配**里,任何把整机
|
||||
# 装起来的用例都会撞上;不是某个测试写坏了)
|
||||
# test 级 —— 只有该用例不参与 -race(其它用例照跑)
|
||||
#
|
||||
# ★ pkg 级排除**必须**确认该包仍有不带 -race 的覆盖(见 go job 里
|
||||
# 单列的"整机启动图判据"步骤),否则等于整包无人跑。
|
||||
#
|
||||
# ╔══════════════════════════════════════════════════════════════════╗
|
||||
# ║ ★★ pkg 级排除是**临时手段**,不是可接受的终态。 ║
|
||||
# ║ ║
|
||||
# ║ 现状的取舍是被迫的,只有两条路: ║
|
||||
# ║ ① 让 ~4% 的偶发红进主干(长红 ⇒ 所有人学会忽略 ⇒ 整道门失效) ║
|
||||
# ║ ② 先挡住、同时派单修根因(本表选这条) ║
|
||||
# ║ ║
|
||||
# ║ **整包永久排除不被接受。** 唯一出口是给 plugin.Registry 的 Set* ║
|
||||
# ║ 加锁(修复单 KERNEL-REGISTRY-SETLOCK),修好后: ║
|
||||
# ║ · 删掉本表里 kernelboot/modules 那一行 ║
|
||||
# ║ · **复跑一次** -race 全仓确认整机判据在 -race 下也稳定 ║
|
||||
# ║ · 删掉 evidence 步骤 ② 的静态断言 ║
|
||||
# ║ 这三步没做完,本线不算收口。 ║
|
||||
# ╚══════════════════════════════════════════════════════════════════╝
|
||||
- name: 计算 -race 排除表并打印
|
||||
id: skip
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# 列名:粒度|目标|原因|修复单号 (用 | 分隔,原因内可含空格)
|
||||
read -r -d '' TABLE <<'EOF' || true
|
||||
test|internal/plugins/webui|TestOpenAIStreamIsActuallyStreaming|gzipResponseWriter 的 mode/wroteHeader/buf 无锁;handler goroutine(发 finish/[DONE])与增量写 goroutine 并发写同一 ResponseWriter(gzip.go:139 读 mode vs gzip.go:206 写 mode)|WEBUI-GZIP-RACE
|
||||
pkg|internal/kernelboot/modules|*(整包)|该包把真实整机装起来 ⇒ 第 4 层 agent 与 plugin-host 并行 Init 时,双方都无锁直写同一 plugin.Registry 的 7 个 Set*(SetLuaVM/SetBaseAPIKey/SetSupervisor/SetTracker/SetConfig/SetStageHost/SetIndexer,registry.go:231-284)。竞争在**内核装配**里,不在本包代码里;任何装整机的用例都会撞上|KERNEL-REGISTRY-SETLOCK
|
||||
EOF
|
||||
|
||||
tests=""; pkgs=""; n=0
|
||||
echo "════════ -race 排除表 ════════"
|
||||
while IFS='|' read -r grain target test reason ticket; do
|
||||
[ -z "${grain// /}" ] && continue
|
||||
# ★ 结构校验:缺原因或缺单号 ⇒ 拒绝(不许无理由/无归属的排除)
|
||||
if [ -z "${reason// /}" ]; then
|
||||
echo "::error::排除项 $target / $test 没有写原因 —— 不许无理由排除"
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "${ticket// /}" ]; then
|
||||
echo "::error::排除项 $target / $test 没有挂修复单号 —— 没人负责的排除会永久腐烂"
|
||||
exit 1
|
||||
fi
|
||||
case "$grain" in
|
||||
test)
|
||||
n=$((n + 1))
|
||||
echo " [$n] test 级: $test"
|
||||
echo " 包: $target"
|
||||
echo " 原因: $reason"
|
||||
echo " 修复单: $ticket"
|
||||
if [ -n "$tests" ]; then tests="$tests|$test"; else tests="$test"; fi
|
||||
;;
|
||||
pkg)
|
||||
n=$((n + 1))
|
||||
echo " [$n] pkg 级: $target (整包不进 -race)"
|
||||
echo " 原因: $reason"
|
||||
echo " 修复单: $ticket"
|
||||
pkgs="$pkgs $target"
|
||||
;;
|
||||
*)
|
||||
echo "::error::排除项 $target 的粒度必须是 test 或 pkg,实际: $grain"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done <<< "$TABLE"
|
||||
|
||||
if [ "$n" -eq 0 ]; then
|
||||
echo " (无排除项)"
|
||||
echo "::warning title=-race 无排除项::排除表已空,可考虑删除本步骤与 evidence 步骤"
|
||||
else
|
||||
echo "════════ 共排除 $n 项(不是静默跳过:每次 CI 都打印本表)════════"
|
||||
echo "::warning title=-race 排除了 $n 项::见本步骤输出(含原因与修复单号)"
|
||||
fi
|
||||
echo "tests=$tests" >> "$GITHUB_OUTPUT"
|
||||
echo "pkgs=$pkgs" >> "$GITHUB_OUTPUT"
|
||||
echo "count=$n" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: go test -race(全仓,应用上面的排除表)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
EXCL="${{ steps.skip.outputs.pkgs }}"
|
||||
SKIP="${{ steps.skip.outputs.tests }}"
|
||||
|
||||
# 从全仓包列表里剔除 pkg 级排除项(按路径后缀精确匹配,
|
||||
# 不用 grep 模糊匹配 —— 否则 kernelboot 会把 kernelboot/modules 一起剔掉)。
|
||||
PKGS=""
|
||||
for p in ${{ steps.pkgs.outputs.pkgs }}; do
|
||||
drop=0
|
||||
for e in $EXCL; do
|
||||
case "$p" in
|
||||
*"/${e#./}") drop=1 ;;
|
||||
esac
|
||||
done
|
||||
[ "$drop" -eq 0 ] && PKGS="$PKGS $p"
|
||||
done
|
||||
|
||||
NPKG=$(echo $PKGS | wc -w)
|
||||
echo " 实际参与 -race 的包数: $NPKG(已剔除 pkg 级排除项)"
|
||||
if [ "$NPKG" -lt 50 ]; then
|
||||
echo "::error::剔除后只剩 $NPKG 个包,疑似误剔 —— 拒绝变成假绿"
|
||||
exit 1
|
||||
fi
|
||||
# ★ 断言被排除的包确实从列表里消失了(防"写了排除但没生效")
|
||||
for e in $EXCL; do
|
||||
if echo "$PKGS" | grep -q -- "/${e#./}"; then
|
||||
echo "::error::pkg 级排除项 $e 仍出现在待测列表里 —— 剔除逻辑失效"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -n "$SKIP" ]; then
|
||||
echo " 应用 test 级排除($SKIP)"
|
||||
go test -race $PKGS -count=1 -timeout 25m -skip "$SKIP"
|
||||
else
|
||||
go test -race $PKGS -count=1 -timeout 25m
|
||||
fi
|
||||
|
||||
# ── 证据步骤:把"已知红"变成每次 CI 都可核对的数字 ──
|
||||
#
|
||||
# ★ 这里**不用 continue-on-error 混过去**(那是"假装没红")。
|
||||
# 做法相反:显式断言两件事都成立,任一不成立 ⇒ 这一步**真红**:
|
||||
# ① webui 那条竞争**确实还在**(报告数 > 0)—— 证明排除表没白排除
|
||||
# ② 内核那 7 个 Set* **确实还无锁**(栈里出现 registry.go 的 Set*)
|
||||
# 修好之后这两条会自然失败,提醒去删排除表 —— 这是**期望的**失败,
|
||||
# 报错信息里直接写了该做什么。
|
||||
- name: 已知红的可核对证据(修好后本步会红,提醒删排除项)
|
||||
run: |
|
||||
set -uo pipefail
|
||||
fail=0
|
||||
|
||||
echo "=== ① webui: gzipResponseWriter 无锁并发写(修复单 WEBUI-GZIP-RACE)==="
|
||||
#
|
||||
# ★ 同样**不**赌竞争必现:实测该竞争单跑 3/8 命中(偶发),
|
||||
# 拿"报告数>0"当断言会在没撞上的那 5 次假绿。
|
||||
# 改成**静态断言**:struct 无 sync 字段 且 commit 不取锁 ⇒ 无锁仍在。
|
||||
# 修好(加锁)后两条件之一变化 ⇒ 本条失败 ⇒ 提醒删排除表。
|
||||
GZ=internal/plugins/webui/gzip.go
|
||||
sync_fields=$(awk '/type gzipResponseWriter struct/,/^}/' "$GZ" | grep -c 'sync\.' || true)
|
||||
lock_calls=$(awk '/func \(g \*gzipResponseWriter\) commit\(/,/^}/' "$GZ" \
|
||||
| grep -cE '\.Lock\(\)|\.RLock\(\)' || true)
|
||||
echo " gzipResponseWriter 的 sync 字段数: $sync_fields"
|
||||
echo " commit() 内的加锁调用数: $lock_calls"
|
||||
if [ "$sync_fields" -ne 0 ] || [ "$lock_calls" -ne 0 ]; then
|
||||
echo "::error title=webui gzip 竞争可能已修::WEBUI-GZIP-RACE 的锁已补(sync字段=$sync_fields 加锁=$lock_calls)—— 请从 ci.yml 排除表删掉该行,并删掉本证据步骤"
|
||||
fail=1
|
||||
fi
|
||||
|
||||
# 附:顺带跑一次,把当前是否撞上记进日志(**不作为判据**)
|
||||
out=$(go test -race ./internal/plugins/webui/ \
|
||||
-run 'TestOpenAIStreamIsActuallyStreaming' -count=1 -timeout 5m 2>&1 || true)
|
||||
echo " (参考)本次 DATA RACE 报告数: $(echo "$out" | grep -c 'WARNING: DATA RACE')(偶发,0 不代表已修)"
|
||||
|
||||
echo "=== ② kernel: agent↔plugin-host 无锁直写 plugin.Registry(修复单 KERNEL-REGISTRY-SETLOCK)==="
|
||||
#
|
||||
# ★ 这里**不跑**偶发竞争来当证据 —— 实测该竞争单步跑 6/6 次撞不到
|
||||
# (它要 92 包并发压满才容易命中),拿它当断言必然假绿。
|
||||
# 改成**静态断言**:直接查那 7 个 Set* 是否仍是"单行无锁直写"形态。
|
||||
# 修好(改成取锁)后形态变化 ⇒ 本条失败 ⇒ 提醒删排除表。因果稳定。
|
||||
SETTERS="SetLuaVM SetBaseAPIKey SetSupervisor SetTracker SetConfig SetStageHost SetIndexer"
|
||||
REG=internal/plugin/registry.go
|
||||
locked=0; total=0
|
||||
for fn in $SETTERS; do
|
||||
total=$((total + 1))
|
||||
if grep -qE "^func \\(r \\*Registry\\) ${fn}\\([^)]*\\)[[:space:]]*\\{[^}]*\\}[[:space:]]*$" "$REG"; then
|
||||
: # 仍是单行直写(未加锁)
|
||||
else
|
||||
echo " 已不再是无锁单行: $fn"
|
||||
locked=$((locked + 1))
|
||||
fi
|
||||
done
|
||||
echo " 仍为无锁直写的 Set*: $((total - locked))/$total"
|
||||
if [ "$locked" -gt 0 ]; then
|
||||
echo "::error title=内核 setter 已加锁::KERNEL-REGISTRY-SETLOCK 可能已被修复($locked/$total 个 Set* 形态已变)—— 请从 ci.yml 排除表删掉 kernelboot/modules 那一行"
|
||||
fail=1
|
||||
fi
|
||||
|
||||
if [ "$fail" -ne 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
echo " 证据核对完成:两个排除项对应的缺陷均仍在(排除表有效)"
|
||||
|
||||
# ── 交叉编译:可在无 cgo 下构建的客户端/工具 ──
|
||||
#
|
||||
# 只有这三个 cmd 支持纯交叉编译。另外三个依赖 cgo(gojieba / onnx):
|
||||
# homed / memgc / homed-kb-migrate → internal/memory(gojieba)
|
||||
# homed → internal/agent/api(onnx)
|
||||
# 它们必须在原生平台构建(见 Makefile 的 build target)。
|
||||
# 哪些 cmd 能纯交叉编译,**本注释不写死**——以 job 内「枚举可纯交叉编译的
|
||||
# cmd」那一步的实测结果为准(`CGO_ENABLED=0 go build` 成功者才纳入)。
|
||||
#
|
||||
# ★ 为什么这里不列名单:名单一定会烂。实测(2026-10-24 本机)已经推翻了
|
||||
# 旧注释的「只有三个」——纯 Go 的是 4 个:
|
||||
# waiter / initconfig / mock-server / homed-key-rotate
|
||||
# (homed-key-rotate 一直是纯 Go 却从没进过清单 ⇒ 白少一个覆盖)
|
||||
# 依赖 cgo 的 8 个(含 gojieba / onnx 传递依赖,必须原生构建):
|
||||
# homed / homed-centroid / homed-depgraph / homed-graph-distill /
|
||||
# homed-graph-migrate / homed-kb-migrate / homed-media-backfill / memgc
|
||||
# 上面的数字是**某次实测的快照**,不是契约;契约是那一步的枚举逻辑。
|
||||
cross:
|
||||
name: Cross-compile
|
||||
runs-on: ubuntu-latest
|
||||
@ -118,12 +391,57 @@ jobs:
|
||||
- goos: windows
|
||||
goarch: amd64
|
||||
ext: ".exe"
|
||||
# ── 目标平台集为什么是这几个 ──
|
||||
#
|
||||
# ★ 这里**仍然是人工选定**的常见交付平台,不是穷举(穷举靠 `go tool dist list`)。
|
||||
# 写死目标集的代价是"某个目标坏了没人知道";缓解办法不是继续加目标,
|
||||
# 而是让**每个目标要构建的 cmd 列表**动态枚举(见下面 "枚举可纯交叉编译的 cmd")——
|
||||
# 那样新增 cmd 不会静默漏覆盖,剩下的风险只限于"没人要的平台"。
|
||||
#
|
||||
# 本机实测(CGO_ENABLED=0 逐包 build,全部退出码 0):
|
||||
# waiter / initconfig / mock-server / homed-key-rotate(纯 Go 那 4 个)
|
||||
# 在 linux/{386,arm,ppc64le}、windows/{386,arm64}、freebsd/amd64 上均通过。
|
||||
# 原先矩阵漏了 windows/arm64 与 linux/386 —— 已补入。
|
||||
- goos: windows
|
||||
goarch: arm64
|
||||
ext: ".exe"
|
||||
- goos: linux
|
||||
goarch: 386
|
||||
ext: ""
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
# 哪些 cmd 能纯交叉编译,**不写死**,而是逐包实测:
|
||||
# `CGO_ENABLED=0 go build` 成功的才纳入。理由是纯 cgo 依赖会随代码漂移
|
||||
# (实测:homed-key-rotate 一直是纯 Go 却没进清单 ⇒ 白少一个覆盖,
|
||||
# 而 memgc 依赖 gojieba,写进清单就是假红)。
|
||||
# 判定在本机与 CI 上是同一条命令、同一个 CGO_ENABLED,结果可复现。
|
||||
- name: 枚举可纯交叉编译的 cmd
|
||||
id: pure
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PURE=""
|
||||
CGO_DEPS=""
|
||||
for c in $(go list ./... | sed -n 's|.*/cmd/\([^/]*\)$|\1|p'); do
|
||||
# ./cmd/gui 是纯 Electron 目录(无 .go 文件),不参与 Go 构建。
|
||||
if [ "$c" = "gui" ]; then continue; fi
|
||||
if CGO_ENABLED=0 go build -o /dev/null "./cmd/$c" 2>/dev/null; then
|
||||
PURE="$PURE $c"
|
||||
else
|
||||
CGO_DEPS="$CGO_DEPS $c"
|
||||
fi
|
||||
done
|
||||
echo " 纯 Go(纳入交叉矩阵):$PURE"
|
||||
echo " 依赖 cgo(只能原生构建):$CGO_DEPS"
|
||||
# 兜底:枚举为空说明逻辑坏了,必须响亮失败而不是静默少跑
|
||||
if [ -z "${PURE// /}" ]; then
|
||||
echo "::error::没有枚举出任何可交叉编译的 cmd —— 枚举逻辑失效"
|
||||
exit 1
|
||||
fi
|
||||
echo "cmds=$PURE" >> "$GITHUB_OUTPUT"
|
||||
- name: 构建 ${{ matrix.goos }}/${{ matrix.goarch }}
|
||||
env:
|
||||
GOOS: ${{ matrix.goos }}
|
||||
@ -132,7 +450,7 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p dist
|
||||
for c in waiter initconfig mock-server; do
|
||||
for c in ${{ steps.pure.outputs.cmds }}; do
|
||||
out="dist/${c}_${{ matrix.goos }}_${{ matrix.goarch }}${{ matrix.ext }}"
|
||||
go build -trimpath -o "$out" "./cmd/${c}"
|
||||
echo " ✓ ${c} ${{ matrix.goos }}/${{ matrix.goarch }}"
|
||||
|
||||
@ -80,7 +80,8 @@ type MemoryBundle struct {
|
||||
// · 少一层间接 ⇒ 少一处可能出错的地方
|
||||
//
|
||||
// 顺序正确性由**声明**驱动:`Build()` 校验缺依赖 / 环 / 重名,
|
||||
// 判据断言关键顺序(见 internal/kernelboot/modules 的判据)。
|
||||
// 判据断言关键顺序(见 internal/kernelboot/modules/registry_test.go 的
|
||||
// TestLayerTopologyIsRespected:用真实模块登记表装配整机,逐层断言层间有序)。
|
||||
//
|
||||
// ## ★ 并发
|
||||
//
|
||||
|
||||
@ -10,15 +10,22 @@
|
||||
// ① 新建一个文件(如 modules/mymod.go)
|
||||
// ② 写一个类型,实现 kernelboot.Module(Name/Requires/Provides/Init)
|
||||
// ③ 在同文件的 init() 里调 modules.Register(&MyMod{})
|
||||
// ④ 在 modules/imports.go 加一行 `_ "...modules"` —— **仅此而已**
|
||||
// ④ 在 `internal/kernelmod/all/all.go` 加一行空白 import —— **仅此而已**
|
||||
//
|
||||
// **不需要**编辑任何中央清单:依赖写在模块**自己**的 Requires() 里。
|
||||
//
|
||||
// ★ 为何要"防漏 import"的判据(见 registry_test.go):
|
||||
// 忘了 import ⇒ 模块**根本不登记** ⇒ 它不参与装配 ⇒ 依赖它的模块报
|
||||
// ★ 为何要"防漏 import"的判据(见 `internal/kernelboot/modules/registry_test.go`):
|
||||
// 忘了 import ⇒ 模块**根本不被编译进来** ⇒ 它不参与装配 ⇒ 依赖它的模块报
|
||||
// "缺依赖"(响亮),但**不依赖它的模块会静默少了那个设施**
|
||||
// (如插件注册表没建 ⇒ 插件全部加载不了,而错误信息指向插件而非根因)。
|
||||
// ⇒ 判据必须自动扫描目录,要求每个模块文件都被 import。
|
||||
//
|
||||
// 判据有两条(缺一不可):
|
||||
//
|
||||
// · `TestEveryRegisteredModuleIsCompiledIn` —— 用 AST 扫源码树里的
|
||||
// `kernelmod.Register(X{})`,与登记表做集合比对(独立真相源,
|
||||
// 手写清单会跟着漏,所以不用清单)
|
||||
// · `TestRealRegistryAssemblesWholeMachine` 等 —— 用真实登记表装配整机、
|
||||
// 断言每个模块都进图且设施就绪、并严格逆序关闭(见同目录测试文件)
|
||||
package modules
|
||||
|
||||
import (
|
||||
|
||||
913
internal/kernelboot/modules/registry_test.go
Normal file
913
internal/kernelboot/modules/registry_test.go
Normal file
@ -0,0 +1,913 @@
|
||||
package modules
|
||||
|
||||
// 「真实登记表 → 整机启动图」的判据(2026-10-24)。
|
||||
//
|
||||
// ╔══════════════════════════════════════════════════════════════════════╗
|
||||
// ║ ★★ 本包(internal/kernelboot/modules)当前**零生产调用**,是死包。 ║
|
||||
// ║ ║
|
||||
// ║ 实测(2026-10-24): ║
|
||||
// ║ · 生产代码里 import 本包的包数 = 0 ║
|
||||
// ║ · BuildModules() / Names() / Register() 的外部调用点 = 0 ║
|
||||
// ║ · 真正的内核模块登记表是 internal/kernelmod(由 kernelmod/all ║
|
||||
// ║ 聚合 import,cmd/homed/kmod_runtime.go 消费) ║
|
||||
// ║ ║
|
||||
// ║ ⚠ 不要把本包当成主路径。它里面的 Register/BuildModules 是 kernelmod ║
|
||||
// ║ 那套注册表的**第二份实现**,留着只会让人下次接错口子。 ║
|
||||
// ║ ║
|
||||
// ║ 本文件为什么仍挂在这里:判据用真实登记表(kernelmod.Registered)装配 ║
|
||||
// ║ 整机,是**占位**位置;挪去 internal/kernelmod/all/ 并删除本包, ║
|
||||
// ║ 需要单独一单(避免与本线混在一起,见下方 TODO)。 ║
|
||||
// ║ 去留决策与执行:另开一单(判据迁 kernelmod/all + 删本目录 + ║
|
||||
// ║ 同步改 registry.go / kernelstate.go / docs 的指引)。 ║
|
||||
// ╚══════════════════════════════════════════════════════════════════════╝
|
||||
//
|
||||
// TODO(死包清理):本包零生产调用。判据已在此跑通并合入;把判据迁到
|
||||
// internal/kernelmod/all/(真实聚合入口、已有同族判据)后删除整个目录。
|
||||
// 触发条件:本线合入 main 后另开单。在那之前**不要**新增对本包的生产依赖。
|
||||
//
|
||||
// ## 这个文件补的是什么(此前完全没有的覆盖)
|
||||
//
|
||||
// `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)。仍是红的,但形态是崩溃不是可读断言。
|
||||
// ★ 结论:这两种改动确实能被发现,但报错可读性差(崩在业务代码里)。
|
||||
// 要"红得干净"得让 kernelboot.Run 对 nil 依赖显式报错 —— 那是业务改动,
|
||||
// 不在本线权限内,已记入待办。
|
||||
//
|
||||
// 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"
|
||||
|
||||
// ★ 真实登记表的**唯一入口**:聚合 import 决定"哪些模块被编译进来"。
|
||||
// 本判据刻意用与 `cmd/homed/kmod_runtime.go` 完全相同的入口
|
||||
// (若那里漏了 import,这里同样会漏 ⇒ 两边一起红,而不是判据自说自话)。
|
||||
_ "github.com/JianFeeeee/HologramAgent/internal/kernelmod/all"
|
||||
)
|
||||
|
||||
// ─────────────────────────── 整机夹具 ───────────────────────────
|
||||
|
||||
// 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 = "../../kernelmod"
|
||||
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])
|
||||
}
|
||||
Reference in New Issue
Block a user