merge: KERNEL-REGISTRY-SETLOCK 全量收尾(Set* 加锁 + CI 排除表撤项 + 门禁空表守卫 + 撤证据步②)

This commit is contained in:
JianFeeeee
2026-10-25 10:19:11 +08:00
14 changed files with 2779 additions and 239 deletions

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

@ -0,0 +1,312 @@
#!/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 排除表:真跑一次坏输入,要求它必须失败 ──
#
# ★ 这里换过三版,每一版的失效方式都值得记:
# v1 `any("没有写原因" in r ...)` —— 只查提示文案;把 if 条件改 false 仍绿
# v2 剥注释后查 `${reason// /}` + `exit 1` —— 两个 token 都在仍可能不成立;
# 实测 `if [ -z "${reason// /}" ] && false; then` 依然绿
# v3 真跑、但拿真实 webui 那一行做坏输入 —— 该排除项修好被删掉时**误报红**
# v4(本条)用**合成行**注入 TABLE 再真跑:
# · 不依赖任何真实排除项 ⇒ 排除项增删不会引起误报
# · 真跑 ⇒ `&& false` 这类「形似而无效」的改动会被抓出来
# v5(ID:19)堵空表假绿:v4 的 `(.*?)` 非贪婪且允许空串 ⇒ 排除表被删到
# 空串时捕获体为空,下游只看 "$n" -eq 0 就静默走「无排除项」分支,
# 排除项漏删/误删永远不会被任何人发现。现在要求**空表必须显式声明**
# (heredoc 里保留一行 `# none`),且下列三条各自有只违反它的坏输入做自测。
#
# 两条守卫必须**各自**用只违反它的输入来验,否则一条会顶另一条的包
# (第一版我把 reason 与 ticket 同时清空,ticket 守卫兜住了,reason 守卫死没死看不出来)。
import subprocess as _sp
import tempfile as _tf
# 空表显式标记:与 ci.yml 里的判据同一串(改一处必须同时改另一处)。
_EMPTY_MARK = "# none"
_tbl = None
for _n, _r in race:
if "计算 -race 排除表并打印" in (_n or ""):
_tbl = _r
if _tbl is None:
fails.append("race job 找不到「计算 -race 排除表并打印」步骤 —— 无法自测排除表守卫")
elif "${{" in _tbl:
fails.append("排除表步骤含 GitHub 表达式,本脚本无法本地执行它(需改用别的验证方式)")
else:
import re as _re
_m = _re.search(r"read -r -d '' TABLE <<'EOF'[^\n]*\n(.*?)\nEOF\n", _tbl, _re.S)
if _m is None:
fails.append("排除表步骤里找不到 TABLE heredoc —— 无法注入合成坏行")
else:
def _run_body(body):
fd, tmp_sh = _tf.mkstemp(suffix=".sh")
with __import__("os").fdopen(fd, "w", encoding="utf-8") as f:
f.write(body)
out = _tf.mktemp()
env = dict(__import__("os").environ)
env["GITHUB_OUTPUT"] = out
try:
return _sp.run(["bash", tmp_sh], capture_output=True, text=True, env=env)
finally:
__import__("os").unlink(tmp_sh)
if __import__("os").path.exists(out):
__import__("os").unlink(out)
def _inject(line):
inner = _m.group(1)
new_inner = (line + "\n" + inner) if inner.strip() else line
return _tbl[:_m.start(1)] + new_inner + _tbl[_m.end(1):]
def _replace_table(new_inner):
"""把 heredoc 的捕获体整段替换成 new_inner(用来造各种空表形态)。"""
return _tbl[:_m.start(1)] + new_inner + _tbl[_m.end(1):]
# 正向:原样必须通过
if _run_body(_tbl).returncode != 0:
fails.append("排除表步骤用当前 ci.yml 实跑不通过(正向基线坏)")
# ── 正向:合法空表(显式 `# none` 标记)必须通过 ──
# ★ 这是"排除表可以合法为空"的必要性证明:最后一条 pkg 行被删掉后,
# 表本来就该空,不能一空就红。
if _run_body(_replace_table(_EMPTY_MARK)).returncode != 0:
fails.append("合法的空表(显式 `# none` 标记)竟被拒 —— 空表本应被允许,"
"守卫不能把「排除项删干净」与「漏写标记」混为一谈")
# ── 反向:空体没有任何标记 ⇒ 必须红(这就是 ID:19 的假绿入口)──
#
# ★ 自测自身的可被改坏性(ID:19 变异 M8/M8d 抓出来的缺口):
# 初版把四条反向样本放在一个 for 循环里。把容器改成 `[] and [...]`
# 或 `[] or [...]` 后循环空转、一条都不跑,而**没人发现**
# —— 实测那份变异下本脚本 exit=0、零失败(自测静默死掉)。
# 补一道"旁路重算 _ran"也不够:M8d 证明旁路能正常数出 4/4,
# 而真正做判断的那段已被空转(两份逻辑脱钩了)。
#
# ⇒ 结论:**不用可被改坏的容器驱动断言**。把四条显式展开,
# 样本集只当"取值源"用(下标直取),不存在"改坏 for 容器"这个面。
# 这样只剩两种可能:样本被删(len 断言报)或某条真的没咬住(逐条报)。
_EMPTY_BAD_CASES = [
("完全空体", ""),
("只有空行", "\n"),
("只有空行+空白", "\n \n"),
("非显式标记的注释", "# 无排除项"),
]
if len(_EMPTY_BAD_CASES) != 4:
fails.append("空表反向样本数变了(期望 4,实际 %d)—— 除新增样本,否则说明样本被删"
% len(_EMPTY_BAD_CASES))
_rejected_checked = [0] # 用 list 当可变计数(闭包内可写)
def _must_reject(idx):
"""断言第 idx 个空表形态**必须**被拒。"""
_rejected_checked[0] += 1
if idx >= len(_EMPTY_BAD_CASES):
# 样本被删:不抛 IndexError,给可读报错(崩溃不如可读断言好)。
fails.append("空表反向样本集缺第 %d 条(只剩 %d 条)—— 样本被删或容器被改坏"
% (idx, len(_EMPTY_BAD_CASES)))
return
_n, _i = _EMPTY_BAD_CASES[idx]
if _run_body(_replace_table(_i)).returncode == 0:
fails.append(
"空表形态「%s」竟通过 ⇒ 空表显式声明守卫已失效"
"(表被删空与有意清空无法区分,排除项漏删会静默变绿)" % _n)
_must_reject(0) # 完全空体
_must_reject(1) # 只有空行
_must_reject(2) # 只有空行+空白
_must_reject(3) # 非显式标记的注释
# ★ 四次调用本身也必须被核对(ID:19 变异 M8d):
# 把上面四行 `_must_reject(...)` 整段删掉/注掉时,前面所有断言代码都还在,
# 但**一次都不会跑** —— 自测静默死掉、门禁照绿(实测 exit=0)。
# 所以把调用次数当成一份合同:删一行也红,不会默默少检。
if _rejected_checked[0] != 4:
fails.append("空表反向自测只调用了 %d/4 次 —— 有检查被删/跳过(自测静默失效)"
% _rejected_checked[0])
_probe = "test|internal/probe/synthetic|TestSyntheticProbe"
# 只打 reason:reason 空、ticket 有效
if _run_body(_inject(_probe + "||PROBE-TICKET")).returncode == 0:
fails.append("合成行「reason 空 / ticket 有效」竟通过 ⇒ reason 守卫已失效"
"(形似而无效的改动,例如 if 条件加 && false)")
# 只打 ticket:reason 有效、ticket 空
if _run_body(_inject(_probe + "|合成探针原因|")).returncode == 0:
fails.append("合成行「reason 有效 / ticket 空」竟通过 ⇒ ticket 守卫已失效")
# ── 4. go job:整机启动图判据必须单列一步跑 kernelmod/all ──
#
# ★ 目标包随 ID:13 从 internal/kernelboot/modules(死包,已删)迁到
# internal/kernelmod/all(真实聚合入口)。本断言跟着改,否则它盯的
# 是一个**已不存在的包** —— 那会变成"永远红"或(更糟)"永远绿"的空转。
GO_BOOT_PKG = "./internal/kernelmod/all/"
gojob = step_runs("go")
boot = [r for _n, r in gojob if GO_BOOT_PKG in r and "go test" in r]
check(bool(boot), "go job 缺少「整机启动图判据」步骤(go test " + GO_BOOT_PKG + ")")
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 步骤语法")

View File

@ -62,39 +62,285 @@ jobs:
- name: go vet ./...
run: go vet ./...
# ── CI 配置门禁自检(.github/scripts/check-ci-gates.py)──
#
# ## 为什么 CI 要检查自己的配置文件
#
# ci.yml 是「静默退化」的高发区:把 race 门的动态枚举改回写死包列表,
# 流水线照样绿(只是罩得少了),没人会发现。该脚本用 yaml.safe_load
# 解析(丢掉注释 ⇒ 「注释里写一句同名机制」顶不了包),断言这些仍在:
# · race 门动态枚举 + 真剔除 cmd/gui + go test -race 真吃了枚举结果
# · -skip 排除表每项都写了原因(缺原因那一步自己会失败)
# · go job 的「整机启动图判据」步骤还在、且带 -count=1
# · cross job 的 cmd 清单是实测枚举、构建真吃枚举结果
# · 每个 run 步骤的 bash 语法
#
# ★ 位置刻意放在 go test 之前:它是纯静态检查(只读 ci.yml,秒级,
# 无网络、无 cgo、不建库不起端口),而 go test ./... 要跑 20 分钟。
# 门禁本身坏掉应该第一时间红,不该等全量测试跑完才发现。
#
# ★ 不假设 runner 自带 pyyaml:先探测、缺了才装(幂等,几秒)。
# 「我以为 runner 有」正是假红/假绿的来源,代价比显式装一行高得多。
- uses: actions/setup-python@v7
with:
python-version: '3.12'
- name: CI 配置门禁自检(动态枚举 / 排除表 / 启动图判据不许静默退化)
run: |
set -euo pipefail
if ! python3 -c 'import yaml' 2>/dev/null; then
echo " pyyaml 缺失,安装"
python3 -m pip install --quiet pyyaml
fi
python3 -c 'import yaml; print(" pyyaml", yaml.__version__)'
python3 .github/scripts/check-ci-gates.py
# ./... 不点名 cmd/gui(该目录是纯 Electron,无 .go 文件):
# 显式 `go test ./cmd/gui` 会报 "no Go files",那是误报,不是缺陷。
- name: go test ./...
run: go test ./... -count=1 -timeout 20m
# 整机启动图判据(internal/kernelmod/all/bootgraph_test.go)。
#
# ## 为什么单列一步(而不是只靠上面的 go test ./...)
#
# 这组判据用**真实模块登记表**装配整机(17 个真模块,全部真 Init),
# 会真的建 SQLite、起 HTTP server、mount overlay、起蒸馏/轮询循环。
# 把它单列出来有两个作用:
# ① 失败时一眼看到是"整机装配"这一层坏了,而不是混在 93 个包的输出里
# ② 它断言的是**跨模块的顺序与资源回收**,是"多跑几次才暴露"的那类,
# 单独一步便于在 flaky 时被识别(这里带 -count=1,不吃缓存)
#
# ★ 判据宿主是 internal/kernelmod/all(真实聚合入口),不是以前的
# internal/kernelboot/modules(那个包零生产调用,已删除)。
#
# ★ 不用 -race(那在 race job 里跑):本判据含真实端口/挂载,
# -race 的插桩会让时序假设变松,两者分开放更便于定位。
- name: 整机启动图判据(真实登记表装配 + 逆序关闭 + 无残留)
run: |
go test ./internal/kernelmod/all/ \
-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 的 gzip 数据竞争(已修)
#
# TestOpenAIStreamIsActuallyStreaming 曾因真竞争被排除在本门外,2026-10-24
# 已解除 skip:7457b05 改成 w 单写者模型(未给 gzip.go 加锁)修掉该竞争。
# 现由主 `go test -race` 门覆盖(排除表里已无该条目)。
#
# ## 已修复的偶发失败:core 的嵌套压力用例(不是竞争)
#
# internal/agent/core: TestStress_NestingReachesStructuralBoundThenUnwindsLIFO
# 曾经偶发红(单独 -run + -race 时 5/12 失败),根因不是数据竞争(从未有过
# DATA RACE 报告),而是**用例自身的固定时间假设**:注入时只等"受害者已在
# 运行",但 a.cancelLLM 是在进入 Chat **之前**才装上的,两者之间存在空档,
# 中断落在空档里就取消不生效,受害者跑满延迟自然结束,栈深永远到不了目标值
# ("第 N 级注入后栈深未达 N:0")。
# 2026-10-24 已把判据改成「等状态」(ed61a8d):先等受害者真的进入可取消的
# LLM 调用(entered 信号)再注入,注入后轮询等栈深达成;延迟参数未动。
# 改后 -race 连跑 10 轮 10/10 PASS,12 路满载下 3/3 PASS。
# 回归时用它复查:
# 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),修好后: ║
# ║ · 删掉本表里 kernelmod/all 那一行 ║
# ║ · **复跑一次** -race 全仓确认整机判据在 -race 下也稳定 ║
# ║ · 删掉 evidence 步骤 ② 的静态断言 ║
# ║ 这三步没做完,本线不算收口。 ║
# ╚══════════════════════════════════════════════════════════════════╝
- name: 计算 -race 排除表并打印
id: skip
run: |
set -euo pipefail
# 列名:粒度|目标|原因|修复单号 (用 | 分隔,原因内可含空格)
read -r -d '' TABLE <<'EOF' || true
# none
EOF
tests=""; pkgs=""; n=0
echo "════════ -race 排除表 ════════"
while IFS='|' read -r grain target test reason ticket; do
# ★ 注释行跳过:空表用一行 `# none` 显式声明(见本步骤开头与下方校验)。
# 先在里出现 `#` 开头的一行时,否则会被当成粒度 `# none` 报
# "粒度必须是 test 或 pkg"—— 标记本身反而把 step 撞红。
case "${grain}" in \#*) continue ;; esac
[ -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"
# ★ 空表必须**显式声明**:heredoc 里保留且只保留一行 `# none`。
# 为何不能允许"什么都没写":那样"表被删空"与"有意清空"无法区分,
# 而门脚本(check-ci-gates.py)的正则捕获到空串后,会静默走下面的
# "无排除项"分支 —— 排除项漏删/误删都不会有任何人发现(假绿,ID:19)。
if [ "$n" -eq 0 ] && ! grep -qEi '^[[:space:]]*#[[:space:]]*none[[:space:]]*$' <<< "$TABLE"; then
echo "::error::-race 排除表为空却没有显式空表标记 —— 请在上面的 heredoc 里保留一行 \`# none\`"
exit 1
fi
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 模糊匹配 —— 否则 kernelmod 会把 kernelmod/all 一起剔掉)。
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
# ── 交叉编译:可在无 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 +364,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 +423,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 }}"

View File

@ -736,8 +736,9 @@ loadOne(name):
### 2.7 声明层已落地(Step 4b 第一步,2026-10-16)
`internal/kernelboot/modules/declarations.go` 把 **main() 里隐含在语句顺序中的
依赖关系**变成显式声明。实测分层结果:
`internal/kernelmod/all` 的聚合 import 把**全部模块引入编译**后,各模块在自己
文件中用 Requires() 把 **main() 里隐含在语句顺序中的依赖关系**变成显式声明。
实测分层结果(2026-10-24 复核:`go test ./internal/kernelmod/all/ -run TestLayeringYieldsParallelism -v`):
```
6 层 / 17 模块,最宽层 9 个可并行(原来是 17 步严格串行)

View File

@ -200,11 +200,72 @@ func TestStress_MixedLevelInterruptsPlusQueuedInputs(t *testing.T) {
//
// Phase A 的形状(100+100 混合)里,中断按严格优先级排队,同一时刻通常只有一层
// 嵌套;真正难的是「中断被中断」逐级下潜。这里按级别**逐级**注入:每一级都等到
// 上一级正在运行才注入,于是必然层层挂起,直到 L4 之上没有更高级别为止。
// 上一级真的处在「可被取消的 LLM 调用中」才注入,于是必然层层挂起,直到 L4 之上
// 没有更高级别为止。
//
// 然后再验证恢复是**严格 LIFO**:L3 → L2 → L1 → 排队任务。
// ---------------------------------------------------------------------------
// gatedProvider 把「受害者正处于一次可取消的 LLM 调用中」变成一个可等待的**状态**:
// Chat 进入即发 entered token,并阻塞到 ctx 取消或 gate 关闭。
type gatedProvider struct {
entered chan struct{} // 每次进入 Chat 发一个 token(Chat 是唯一可取消的步骤)
gate chan struct{} // 关闭后允许 Chat 自然返回(只用于排空阶段)
cancelled atomic.Int64
completed atomic.Int64
}
func newGatedProvider() *gatedProvider {
return &gatedProvider{entered: make(chan struct{}, 64), gate: make(chan struct{})}
}
func (p *gatedProvider) Name() string { return "gated" }
// Chat 把「受害者正处于一次可取消的 LLM 调用中」变成一个可等待的**状态**。
//
// 为什么必须这样(ID:12 的根因):a.cancelLLM 是在进入 Chat **之前**装好的
// (task.go: 先 a.cancelLLM = fCancel,再 chatStreamWithFallback → Chat),
// 所以「收到 entered token」等价于「此刻注入的中断一定能换成一次真正的取消」。
// 反过来说,只用固定延迟(原实现 300ms)当窗口,注入就可能落在
// 「任务已被选中运行」与「cancelLLM 已装上」之间的空档:那时 registerInterrupt
// 已打印 preempt 日志并置位让位信号,但 cancelCurrentLLM() 拿到的 cancel 仍是 nil
// ⇒ 取消不生效、受害者跑满延迟后以 outcomeDone 结束(runTaskSteps 在 outcomeDone
// 处直接返回,不再经过安全点)⇒ 栈深永远到不了目标值。实测该空档在高负载/-race
// 插桩下命中 12/30 轮。
//
// 阻塞到 ctx 取消或 gate 关闭:因此不存在「错过窗口」这回事,也不再需要
// 靠延迟长短来迁就调度速度(延迟参数保持原值不动)。
func (p *gatedProvider) Chat(ctx context.Context, _ *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
select {
case p.entered <- struct{}{}:
default: // 缓冲满:不代表状态缺失,只为避免测试替身把任务卡死
}
select {
case <-p.gate:
p.completed.Add(1)
return &agentAPI.CompletionResponse{Content: "fixed-reply"}, nil
case <-ctx.Done():
p.cancelled.Add(1)
return nil, ctx.Err()
}
}
func (p *gatedProvider) ChatStream(context.Context, *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) {
return nil, errors.New("gated provider: 非流式")
}
func (p *gatedProvider) MaxContextTokens() int { return 8192 }
// waitEntered 等到受害者真的进入一次可取消的 LLM 调用。
func waitEntered(t *testing.T, p *gatedProvider, wait time.Duration) {
t.Helper()
select {
case <-p.entered:
case <-time.After(wait):
t.Fatalf("%v 内没有任何任务进入可取消的 LLM 调用", wait)
}
}
func waitForRunning(a *Agent, pred func(*Task) bool, wait time.Duration) *Task {
deadline := time.Now().Add(wait)
for time.Now().Before(deadline) {
@ -216,14 +277,28 @@ func waitForRunning(a *Agent, pred func(*Task) bool, wait time.Duration) *Task {
return nil
}
func TestStress_NestingReachesStructuralBoundThenUnwindsLIFO(t *testing.T) {
const llmDelay = 300 * time.Millisecond // 窗口要够宽,让每次 cancel 都来得及生效
// waitStackDepth 等到中断栈真的达到 want 帧(轮询,不假设固定时间)。
func waitStackDepth(t *testing.T, a *Agent, want int, wait time.Duration) {
t.Helper()
deadline := time.Now().Add(wait)
for {
n := len(a.DumpScheduler().SuspendStack)
if n >= want {
return
}
if time.Now().After(deadline) {
t.Fatalf("等待中断栈深达到 %d 超时(%v):当前 %d", want, wait, n)
}
time.Sleep(time.Millisecond)
}
}
func TestStress_NestingReachesStructuralBoundThenUnwindsLIFO(t *testing.T) {
plugin.RegisterFactory("stress_nest_builtin", func(string, map[string]interface{}) (sdk.Plugin, error) {
return nil, nil
})
sp := &fixedDelayProvider{delay: llmDelay}
sp := newGatedProvider()
bus := newLevelRecorder()
a := New(AgentConfig{
ID: "stress-nest",
@ -239,7 +314,7 @@ func TestStress_NestingReachesStructuralBoundThenUnwindsLIFO(t *testing.T) {
// 放一个排队任务(无级别)当栈底。
a.io.InjectInput("cli", "text", map[string]interface{}{"content": "nest-base"})
if r := waitForRunning(a, func(tk *Task) bool { return tk.Class == TaskQueued }, 5*time.Second); r == nil {
if r := waitForRunning(a, func(tk *Task) bool { return tk.Class == TaskQueued }, 10*time.Second); r == nil {
t.Fatal("排队任务未开始运行")
}
@ -256,32 +331,27 @@ func TestStress_NestingReachesStructuralBoundThenUnwindsLIFO(t *testing.T) {
{LevelCritical, "stress_nest_builtin", LevelInteractive}, // 受害者=L3
}
for i, st := range steps {
// ① 等预期的受害者成为运行任务(类别/级别)。
if waitForRunning(a, func(tk *Task) bool {
if st.lv == LevelBackground {
return tk.Class == TaskQueued
}
return tk.Class == TaskInterrupt && tk.Level == st.run
}, 5*time.Second) == nil {
}, 10*time.Second) == nil {
t.Fatalf("第 %d 级(%v)注入前未等到预期的受害者运行", i+1, st.lv)
}
// ② 等它真的进入**可取消的** LLM 调用——这是「取消会生效」的充要状态,
// 不是时间估计。只有到这里注入,抢占才必然换出一次挂起。
waitEntered(t, sp, 10*time.Second)
a.io.InjectInterruptTextOpts(st.src, "cli", fmt.Sprintf("nest-%d", i+1),
agentIO.InjectOptions{Priority: map[Level]string{
LevelBackground: "L1", LevelMessage: "L2",
LevelInteractive: "L3", LevelCritical: "L4",
}[st.lv]})
// 等这一层真的压进栈(否则下一级的"受害者"条件会被误判)。
deadline := time.Now().Add(5 * time.Second)
for {
if len(a.DumpScheduler().SuspendStack) >= i+1 {
break
}
if time.Now().After(deadline) {
t.Fatalf("第 %d 级注入后栈深未达 %d:%d",
i+1, i+1, len(a.DumpScheduler().SuspendStack))
}
time.Sleep(time.Millisecond)
}
// ③ 等这一层真的压进栈(否则下一级的"受害者"条件会被误判)。
waitStackDepth(t, a, i+1, 10*time.Second)
}
// 结构上限:4 帧 = 排队(L0) + L1 + L2 + L3 挂起,L4 运行中。
@ -292,7 +362,8 @@ func TestStress_NestingReachesStructuralBoundThenUnwindsLIFO(t *testing.T) {
t.Fatal("已到结构上限,L4 之上不该再有下潜余量")
}
// 排空:4 层必须逐层弹回,且顺序严格 LIFO。
// 排空:放开闸门,让仍在运行的 L4 自然收尾,4 层再逐层弹回,顺序严格 LIFO。
close(sp.gate)
snap := waitQuiescent(t, a, 5, 30*time.Second)
if n := len(snap.SuspendStack); n != 0 {
t.Fatalf("排空后中断栈=%d,期望 0", n)

View File

@ -230,6 +230,36 @@ func diagnoseUnresolvable(pending []Module, declared map[string]bool, owner map[
return &UnresolvableError{Missing: missing, Cycle: cycle, Declared: declaredList}
}
// verifyLayerDependencies 核对本层各模块的依赖是否**已由更早的层实际产出**。
//
// 返回 nil 表示全部就绪;否则返回 *DependencyNotReadyError(带模块名与缺失设施名)。
// 判据形状与 satisfied 一致(Requires ⊆ produced),但数据源是 produced(事实)
// 而非 Build 里的 ready(声明推演)—— 这正是它能咬住"层顺序被改坏"的原因。
func verifyLayerDependencies(li int, layer []Module, produced map[string]bool) error {
missing := map[string][]string{}
for _, m := range layer {
var lack []string
for _, r := range m.Requires() {
if !produced[r] {
lack = append(lack, r)
}
}
if len(lack) > 0 {
sort.Strings(lack)
missing[m.Name()] = lack
}
}
if len(missing) == 0 {
return nil
}
producedList := make([]string, 0, len(produced))
for f := range produced {
producedList = append(producedList, f)
}
sort.Strings(producedList)
return &DependencyNotReadyError{Layer: li, Missing: missing, Produced: producedList}
}
// RunOptions 控制执行。
type RunOptions struct {
// MaxParallel 是**层内**的并发上限(<=0 表示不限制)。
@ -270,13 +300,74 @@ type RunResult struct {
Order []string
}
// DependencyNotReadyError 是"执行期"依赖校验失败:某模块要开工,但它需要的设施
// **在已经真正跑完的层里**找不到。
//
// ★ 与 Build/diagnoseUnresolvable 的分工(**刻意不重复实现**):
//
// Build(构建期) :看**声明**——Requires 有没有人 Provides、有没有环。
// 本错误(执行期,Run):看**事实**——按这份 plan 真的跑完的层里,产出了什么。
//
// 两者不可互相替代:声明全对、图也自洽,plan 的**层顺序**仍可能被改坏
// (手改 Layers / 旧图/新代码混装 / 有人把某层提前)。那时 Build 不会报错,
// 模块的 Init 拿到 nil 依赖 → 崩在业务代码里。实测形态:把 lua 排到 llm 之后,
// llm.buildProviderManager 收到 0x0 的 LuaVM,ConfigureConcurrency 里 SIGSEGV。
//
// ⇒ 这条判据的判据对象是**实际执行进度**,不是声明,所以它咬得住"顺序被破坏"。
type DependencyNotReadyError struct {
// Layer 是出问题的层号(0 起)。
Layer int
// Missing 是 模块名 → 它缺的设施名(各自升序)。
Missing map[string][]string
// Produced 是此前各层**实际产出**的设施名(升序),供对照"哪些还没跑"。
Produced []string
}
func (e *DependencyNotReadyError) Error() string {
mods := make([]string, 0, len(e.Missing))
for m := range e.Missing {
mods = append(mods, m)
}
sort.Strings(mods)
var b strings.Builder
fmt.Fprintf(&b, "kernelboot: 第 %d 层装配前校验失败 —— 依赖未就绪", e.Layer)
b.WriteString("(启动计划的层顺序与模块声明不一致,或图与代码漂移): ")
for i, m := range mods {
if i > 0 {
b.WriteString(";")
}
fmt.Fprintf(&b, "模块 %s 缺设施 %v", m, e.Missing[m])
}
fmt.Fprintf(&b, "。此前各层已实际产出的设施: %v", e.Produced)
b.WriteString("。\n" +
" 为什么必须在这里拦住:依赖没就绪就调 Init,模块拿到的是 nil," +
"崩在业务代码里(实测 lua 排到 llm 之后 ⇒ llm 拿 0x0 LuaVM,SIGSEGV)。\n" +
" 修复:让提供者在**更早的层**(检查各模块的 Requires/Provides 是否与图一致," +
"并重新生成依赖图)")
return b.String()
}
// Run 按计划执行:逐层 Init,层内有界并行。
//
// ## 执行期依赖校验(每层 Init **之前**)
//
// 逐层维护"这一层之前**真的跑完**的模块产出了哪些设施"(produced),
// 每层开工前先核对本层各模块的 Requires ⊆ produced:
//
// · 缺则**立刻返回** DependencyNotReadyError(指名模块名前、缺哪些设施),
// 本层**一个 Init 都不调** —— 宁可什么都不装,也不要装一半再崩
// · 这一层全部成功后才把本层 Provides 记入 produced(与 syncbus 的层边界同语义)
//
// ★ 为什么不复用 Build 的结论:Build 校验的是**声明**(图能否解开)。
// 声明全对时层顺序仍可能被改坏,那时 Build 绿、Init 崩。见 DependencyNotReadyError。
//
// 失败策略(文档 §2.3.3):**任何 Init 失败都令整体失败**。
// 不静默跳过、不"带着半初始化状态继续"——后者正是病 A 的形态。
//
// 同层内若**多个**模块失败,返回第一个(按名字序,稳定);
// 但**不等同于只跑一个**:同层其余模块仍会被执行完(避免半途中断造成更乱的状态)。
// (依赖未就绪属于"开工前就发现",那时本层还没有任何模块动过,故一个都不跑。)
func Run(plan *Plan, opts RunOptions) (*RunResult, error) {
if plan == nil {
return nil, fmt.Errorf("kernelboot: 计划为 nil")
@ -284,6 +375,10 @@ func Run(plan *Plan, opts RunOptions) (*RunResult, error) {
res := &RunResult{}
var orderMu sync.Mutex
// produced 是**执行期事实**:只有真的跑完的层才贡献设施。
// 它刻意不从 plan 里预填(预填就等于相信声明 ⇒ 变回构建期校验,抓不到顺序被改坏)。
produced := map[string]bool{}
ks := opts.State
if ks == nil {
ks = NewKernelState()
@ -312,6 +407,14 @@ func Run(plan *Plan, opts RunOptions) (*RunResult, error) {
if len(layer) == 0 {
continue
}
// ★ 装配前校验:本层每个模块要的设施,必须在**此前真正跑完**的层里产出过。
// 放在这里(而不是循环中途逐个模块查)⇒ 本层一个 Init 都不调就拒绝,
// 避免"前半层已经装了一半"的脏状态。
if err := verifyLayerDependencies(li, layer, produced); err != nil {
return res, err
}
limit := opts.MaxParallel
if limit <= 0 || limit > len(layer) {
limit = len(layer)
@ -376,6 +479,14 @@ func Run(plan *Plan, opts RunOptions) (*RunResult, error) {
}
res.LayerCount++
// ★ 执行期事实:本层全部成功 ⇒ 它提供的设施才"真的存在"。
for _, m := range layer {
for _, p := range m.Provides() {
if p != "" {
produced[p] = true
}
}
}
// ★ 层边界:把本层贡献的设施标记就绪(设计 §2.6.2)。
// 在层内并发**全部完成之后**统一标记 ⇒ 就绪顺序确定、可复现。
if opts.Bus != nil {

View File

@ -80,7 +80,10 @@ type MemoryBundle struct {
// · 少一层间接 ⇒ 少一处可能出错的地方
//
// 顺序正确性由**声明**驱动:`Build()` 校验缺依赖 / 环 / 重名,
// 判据断言关键顺序(见 internal/kernelboot/modules 的判据)。
// `Run()` 在执行每一层之前再核一次"实际已产出的设施"是否涵盖本层依赖
// (声明对、但计划层顺序被改坏时也能拦住,见 DependencyNotReadyError)。
// 判据断言关键顺序(见 internal/kernelmod/all/bootgraph_test.go 的
// TestLayerTopologyIsRespected:用真实模块登记表装配整机,逐层断言层间有序)。
//
// ## ★ 并发
//

View File

@ -1,94 +0,0 @@
// Package modules 是内核模块的**登记表**(不含任何具体模块的实现)。
//
// ## 设计意图(用户设计)
//
// > 每个内核模块显式各自声明依赖 … 最先初始化的 core 自然不依赖任何模块,
// > 下一步初始化的依赖 core 以此类推
//
// ## 怎么加一个新模块(这是本设计的**验收标准**)
//
// ① 新建一个文件(如 modules/mymod.go)
// ② 写一个类型,实现 kernelboot.Module(Name/Requires/Provides/Init)
// ③ 在同文件的 init() 里调 modules.Register(&MyMod{})
// ④ 在 modules/imports.go 加一行 `_ "...modules"` —— **仅此而已**
//
// **不需要**编辑任何中央清单:依赖写在模块**自己**的 Requires() 里。
//
// ★ 为何要"防漏 import"的判据(见 registry_test.go):
// 忘了 import ⇒ 模块**根本不登记** ⇒ 它不参与装配 ⇒ 依赖它的模块报
// "缺依赖"(响亮),但**不依赖它的模块会静默少了那个设施**
// (如插件注册表没建 ⇒ 插件全部加载不了,而错误信息指向插件而非根因)。
// ⇒ 判据必须自动扫描目录,要求每个模块文件都被 import。
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
}

View File

@ -0,0 +1,168 @@
package kernelboot
// 执行期依赖校验(verifyLayerDependencies)的纯逻辑判据(2026-10-24,ID:14)。
//
// ## 为什么与 Build 的判据分开
//
// Build 的判据(TestBuildRejectsMissingDependency / …Cycle …)验的是**声明**:
// 一个模块 Requires 了没人 Provides 的设施,或成环 ⇒ Build 就得拒绝。
//
// 本文件的判据假设**声明完全合法**(Build 能算出图),只把算好的 Plan 的
// 层顺序改坏 —— 这才是 Build 绿而 Run 崩的那类故障(实测 SIGSEGV 见
// internal/kernelmod/all/runtime_dep_test.go)。
//
// ## 覆盖的四类形状
//
// ① 正序(正确开销) ⇒ 不误报
// ② 提供者排到消费者之后 ⇒ 指名模块名 + 缺的设施
// ③ 提供者整层缺失 ⇒ 第 0 层就拒绝,一个 Init 都不跑
// ④ 层内多模块各缺不同依赖 ⇒ 逐个模块报(不是只报第一个)
//
// 另有一条"M2 型变异"的自检:如果 produced 从 plan 预填(拿声明当事实),
// ② ③ 会变成**永不触发**的假绿 —— 判据必须能把它抓出来。
import (
"errors"
"strings"
"testing"
)
// chainPlan 造一个三层的合法计划:c → b → a(c 无依赖,a 最上层)。
func chainPlan(t *testing.T) *Plan {
t.Helper()
mods := []Module{
&fakeModule{name: "a", provides: []string{"fa"}, requires: []string{"fb"}},
&fakeModule{name: "b", provides: []string{"fb"}, requires: []string{"fc"}},
&fakeModule{name: "c", provides: []string{"fc"}},
}
plan, err := Build(mods)
if err != nil {
t.Fatalf("前置:声明必须合法(本文件只测顺序被改坏): %v", err)
}
return plan
}
// ★★★ ① 正序不误报。
func TestRunGuardAcceptsCorrectOrder(t *testing.T) {
if _, err := Run(chainPlan(t), RunOptions{}); err != nil {
t.Fatalf("★ 合法顺序被判成缺依赖(误报): %v", err)
}
}
// ★★★ ② 提供者排到消费者之后 ⇒ 指名道姓报错,且 Init 一个都没跑。
func TestRunGuardRejectsSwappedLayers(t *testing.T) {
plan := chainPlan(t)
// 把 b(提供 fb)与其消费者 a 交换:a 先跑,它要的 fb 还没产出。
swapped := &Plan{Layers: [][]Module{
plan.Layers[0], // c
plan.Layers[2], // a ← 原本在最后
plan.Layers[1], // b
}}
var inited []string
for _, l := range swapped.Layers {
for _, m := range l {
mm := m.(*fakeModule)
inner := mm.onInit
mm.onInit = func() {
inited = append(inited, mm.name)
if inner != nil {
inner()
}
}
}
}
_, err := Run(swapped, RunOptions{})
if err == nil {
t.Fatal("★ 顺序被改坏却装配成功 —— 校验没生效(模块会拿到 nil 依赖)")
}
var dnr *DependencyNotReadyError
if !errors.As(err, &dnr) {
t.Fatalf("★ 期望 *DependencyNotReadyError,实际 %T: %v", err, err)
}
if got, want := strings.Join(dnr.Missing["a"], ","), "fb"; got != want {
t.Errorf("★ 缺依赖报错不准:期望 a 缺 fb,实际 %v", dnr.Missing)
}
if len(inited) != 1 || inited[0] != "c" {
t.Errorf("★ 违规层不该有任何模块被 Init(只应跑过 c 层),实际跑了 %v", inited)
}
for _, sub := range []string{"第 1 层", "模块 a", "fb"} {
if !strings.Contains(err.Error(), sub) {
t.Errorf("★ 报错文本缺少 %q(原文: %s)", sub, err.Error())
}
}
}
// ★★★ ③ 提供者整层缺失 ⇒ 第 0 层就拒绝,produced 为空。
func TestRunGuardRejectsMissingProviderLayer(t *testing.T) {
plan := chainPlan(t)
cut := &Plan{Layers: [][]Module{plan.Layers[1], plan.Layers[2]}}
_, err := Run(cut, RunOptions{})
if err == nil {
t.Fatal("★ 提供者整层缺失却装配成功")
}
var dnr *DependencyNotReadyError
if !errors.As(err, &dnr) {
t.Fatalf("★ 期望 *DependencyNotReadyError,实际 %T: %v", err, err)
}
if dnr.Layer != 0 {
t.Errorf("★ 应在第 0 层拒绝,实际第 %d 层", dnr.Layer)
}
if len(dnr.Produced) != 0 {
t.Errorf("★ 第 0 层之前不该有产出,实际 %v", dnr.Produced)
}
}
// ★★★ ④ 同层多模块各缺不同依赖 ⇒ 逐个报(不是只报第一个,否则要修 N 轮)。
func TestRunGuardReportsEveryMissingModuleInLayer(t *testing.T) {
// 把 a 与 b 放进同一层,且都不提供任何东西 ⇒ 两者都缺依赖。
merged := &Plan{Layers: [][]Module{
{&fakeModule{name: "a", requires: []string{"fb"}},
&fakeModule{name: "b", requires: []string{"fc"}}},
}}
_, err := Run(merged, RunOptions{})
if err == nil {
t.Fatal("★ 缺依赖却装配成功")
}
var dnr *DependencyNotReadyError
if !errors.As(err, &dnr) {
t.Fatalf("★ 期望 *DependencyNotReadyError,实际 %T: %v", err, err)
}
if len(dnr.Missing) != 2 {
t.Fatalf("★ 只报了 %d 个模块缺依赖(应 2 个,否则要修 N 轮才看得全): %v",
len(dnr.Missing), dnr.Missing)
}
if strings.Join(dnr.Missing["a"], ",") != "fb" || strings.Join(dnr.Missing["b"], ",") != "fc" {
t.Errorf("★ 报错内容不准: %v", dnr.Missing)
}
}
// ★★★ 自检(防 M2 型变异):把 produced 预填成"全部声明" ⇒ 上面 ②③ 必须红。
//
// 这条不是测被测代码,而是测**判据具不具备判别力**:
// 若有人把执行期校验改成"从 plan 预填 produced"(等于相信声明),
// 它就退化成 Build 的重复实现,抓不到顺序被改坏 —— 那时本文件的 ②③
// 会变绿(假绿)。所以这里直接把这种实现形态跑一遍,要求它**确实**放行违规,
// 从而证明 ②③ 的红不是"任何实现都会红"。
func TestGuardPrefilledFromPlanIsDetectablyWeaker(t *testing.T) {
plan := chainPlan(t)
prefilled := map[string]bool{}
for _, l := range plan.Layers {
for _, m := range l {
for _, p := range m.Provides() {
prefilled[p] = true
}
}
}
// 违规层:a 在第 0 层,但它要的 fb 由第 2 层的 b 提供。
layer := []Module{plan.Layers[2][0]}
if err := verifyLayerDependencies(0, layer, prefilled); err != nil {
t.Fatalf("★ 预填 produced(拿声明当事实)竟报错 —— 说明本判据的选择无法区分两种实现,需重设: %v", err)
}
// 同一层、同一顺序,用**空** produced(真事实)必须报错 ⇒ 判据有判别力。
if err := verifyLayerDependencies(0, layer, map[string]bool{}); err == nil {
t.Fatal("★ 空 produced 也不报错 —— verifyLayerDependencies 没有判别力")
}
}

View File

@ -24,7 +24,6 @@ import (
"github.com/JianFeeeee/HologramAgent/internal/kernelboot"
"github.com/JianFeeeee/HologramAgent/internal/kernelmod"
"github.com/JianFeeeee/HologramAgent/internal/memory"
"github.com/JianFeeeee/HologramAgent/internal/supervisor"
"github.com/JianFeeeee/HologramAgent/pkg/types"
agentPkg "github.com/JianFeeeee/HologramAgent/internal/agent"
@ -246,15 +245,30 @@ func buildAgent(k *kernelboot.KernelState) *agentCore.Agent {
return agent
}
// wirePluginSDK 把内核依赖注入插件注册表(原 cmd/homed 的整块平移)。
// wirePluginSDK 注入**只有 agent 才知道**的那两项(KERNEL-REGISTRY-SETLOCK 去重后)。
//
// ## 为什么这里不再注入那 7 项底层依赖(2026-10-24 去重)
//
// 原先本函数与 pluginhost.injectBaseDeps 各自把 LuaVM/BaseAPIKey/Supervisor/
// Tracker/Config/StageHost/Indexer 写一遍,而两个模块同处启动图第 4 层、
// **并行执行** ⇒ 同一批字段被两个 goroutine 无锁写(实测 4% 概率报 DATA RACE)。
//
// 判掉哪一侧:留 pluginhost,砍这边。理由是**必要性不对称**:
//
// · pluginhost 的注入是**必需**的 —— 它自己紧接着就 LoadSkipping 拉插件,
// 而它刻意 `Requires()` 不含 agent(核心收益:插件不必等 agent 那 35 秒)。
// 砍掉它 ⇒ 第 4 层加载的插件拿到全 nil 依赖。
// · agent 这边注入的这批值是**同一批 ks.* 字段的同一份值**,写第二次
// 没有新增信息;砍掉它 ⇒ 语义不变(pluginhost 侧的 nil 守卫更严,
// Supervisor 只因 FacSupervisor 已在 Requires 里而恒非 nil)。
//
// ## 注意:去重**不能**替代加锁
//
// 去重后本函数仍有 SetStatusProvider / SetDeviceAuthQuery 两项要写,而
// pluginhost 同时可能正在加载插件(buildSDK 读 status)⇒ 写读依然并发。
// 所以 registry 侧 Set*/读侧的锁是**真正的修复**,去重只是消除了一处
// 无意义的重复写。两件事都做,缺一不可。
func wirePluginSDK(k *kernelboot.KernelState) {
k.PluginRegistry.SetLuaVM(k.LuaVM)
k.PluginRegistry.SetBaseAPIKey(k.BaseAPIKey)
k.PluginRegistry.SetSupervisor(supervisor.NewSDKAdapter(k.Supervisor))
k.PluginRegistry.SetTracker(k.Tracker)
k.PluginRegistry.SetConfig(k.Cfg)
k.PluginRegistry.SetStageHost(k.StageHost)
k.PluginRegistry.SetIndexer(k.Memory.Indexer)
k.PluginRegistry.SetStatusProvider(k.Agent)
}

View File

@ -0,0 +1,923 @@
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」。
// 变异亲测见本文件「## 失败面」一节里标题后注「逐条实测过」的那一处
// (不是 TestEveryRegisteredModuleIsCompiledIn 上方注「已实测咬红」的那一处)
// 第 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])
}

View File

@ -0,0 +1,236 @@
package all
// ID:14 · Run 的**执行期**依赖校验判据(2026-10-24)。
//
// ## 与构建期校验的分工(别把这条当成 Build 的重复实现)
//
// - 构建期(kernelboot.Build → diagnoseUnresolvable):看**声明**。
// Requires 有没有人 Provides、有没有环。声明写对了就绿。
// - 执行期(kernelboot.Run → verifyLayerDependencies,本文件测的):看**事实**。
// 按这份 plan 真的跑完的层里产出了什么。
//
// 两者不可互相替代。**声明全对、图也自洽**时,plan 的层顺序仍可能被改坏
// (手改 Layers / 图与代码漂移 / 有人把某层提前),此时 Build 依然绿,
// 而模块 Init 拿到的是 nil 依赖 ⇒ 崩在业务代码里。
//
// 本判据的复现形状就是实测撞到的那个:让 **llm 排到 lua 之前**,
// llm.buildProviderManager 收到 0x0 的 LuaVM,在
// `luaVM.ConfigureConcurrency(...)`(internal/lua/vm.go:212 取 v.mu)里 SIGSEGV。
//
// ## 为什么判据必须用真实登记表
//
// 用假模块自造一份错误的层顺序,只能证明"这段校验逻辑会报错",
// 不能证明"真实 17 个模块的依赖形状被它覆盖"。这里直接拿
// kernelmod.Registered() 算出的真实 plan 做变异(删除首层 / 交换两层),
// 报错里的模块名与设施名都是真名字。
import (
"errors"
"path/filepath"
"strings"
"testing"
"github.com/JianFeeeee/HologramAgent/internal/kernelboot"
"github.com/JianFeeeee/HologramAgent/internal/kernelmod"
"github.com/JianFeeeee/HologramAgent/pkg/types"
)
// realPlan 用真实登记表算分层计划(与生产 dev 路径同一个入口)。
func realPlan(t *testing.T) *kernelboot.Plan {
t.Helper()
plan, err := kernelboot.Build(kernelmod.Registered())
if err != nil {
t.Fatalf("真实登记表算不出启动图: %v", err)
}
if len(plan.Layers) < 4 {
t.Fatalf("★ 真实计划只有 %d 层 —— 这条判据的变异前提不成立(层数太少无法制造顺序破坏)",
len(plan.Layers))
}
return plan
}
// layerNames 返回某层的模块名(升序,便于断言消息)。
func layerNames(layer []kernelboot.Module) []string {
out := make([]string, 0, len(layer))
for _, m := range layer {
out = append(out, m.Name())
}
return out
}
// emptyState 造一个最小可用的 KernelState:只填 Group 0 的产物。
//
// ★ 为什么必须填 Cfg:`config` 模块的 Init 会显式拒绝 nil 的 ks.Cfg
// (它自己就有前置检查)。不填的话"正确的 L0 也跑不起来",
// 那样就分不清红是因为顺序变了还是因为夹具没准备好。
//
// ★ 为什么连 Daemon.DataDir 一起填:万一有人在本判据上做"临时摸掉守卫"的
// 变异,模块会真的跑到写盘那一行。不填的话 text-memory 会往**仓内**
// 当前目录下的 memory/text 写(实测产生过 internal/kernelmod/all/memory/)。
// 夹具不能往仓库里漏文件。
func emptyState(t *testing.T) *kernelboot.KernelState {
t.Helper()
dir := t.TempDir()
ks := kernelboot.NewKernelState()
ks.Cfg = &types.Config{}
ks.Cfg.Daemon.DataDir = dir
ks.Cfg.Plugin.Dir = filepath.Join(dir, "plugins")
ks.DataDir = dir
ks.AgentWorkDir = filepath.Join(dir, "agentfs")
ks.HTTPAddr = "127.0.0.1:0"
return ks
}
// ★★★ 判据 A(正向基线):**正确的层顺序下,执行期校验不误报**。
//
// 取真实 plan 的**第一层**单独跑:模块(config / kernel-api)本来就绪条件
// 为空,若能通过 ⇒ 校验没把"合法的第 0 层"当成缺依赖。
//
// (整机正序不误报由 internal/kernelmod/all 的整机判据覆盖(bootgraph_test.go):
// 它用真实 plan 跑完全部 5 层,装配成功后资产/关闭/泄漏全部断言。
// 这里只做最小正向基线,避免重复一份整机夹具。)
func TestRunRuntimeGuardDoesNotFireOnCorrectPrefix(t *testing.T) {
plan := realPlan(t)
prefix := &kernelboot.Plan{Layers: [][]kernelboot.Module{plan.Layers[0]}}
if _, err := kernelboot.Run(prefix, kernelboot.RunOptions{State: emptyState(t)}); err != nil {
t.Fatalf("★ 合法前缀(第 0 层 %v)被判成缺依赖 —— 校验误报: %v",
layerNames(plan.Layers[0]), err)
}
}
// ★★★ 判据 B(**复现原始崩溃条件**):把提供者排到消费者之后 ⇒ 必须
// 返回可读的 DependencyNotReadyError,**不是** SIGSEGV。
//
// 做法:交换真实 plan 的第 1 层与第 2 层。
// 交换后 lua/memory/onnx-parser/knowledge-migration 都排到了 llm/text-memory/
// knowledge-store 之后 —— 这正是"改层顺序"的最小形态,也是实测崩溃的场景
// (llm 拿 0x0 LuaVM)。
//
// 断言三件事:
// 1. 错误类型是 *DependencyNotReadyError(不是 panic、不是 Init 透传的错)
// 2. 报错**指名**哪个模块缺哪个设施(可读、可定位)
// 3. 报错发生在**装配之前**:此前各层实际产出的设施只有 L0 那两个,
// 且 llm 依赖的 LuaVM 从未被创建(ks.LuaVM == nil)
func TestRunRejectsLayerOrderViolation(t *testing.T) {
plan := realPlan(t)
// 前置:真实 plan 的第 1 层确实提供 lua/memory/onnx,第 2 层确实消费它们。
// 若前提变了(有人调整了模块依赖),下面交换就不再"制造出违规" ⇒ 判据变空转。
providesIn := func(layer []kernelboot.Module, fac string) bool {
for _, m := range layer {
for _, p := range m.Provides() {
if p == fac {
return true
}
}
}
return false
}
for _, fac := range []string{kernelmod.FacLua, kernelmod.FacMemory, kernelmod.FacONNX} {
if !providesIn(plan.Layers[1], fac) {
t.Fatalf("★ 前提不成立:第 1 层不再提供 %s —— 本判据的变异会变成空转(不会制造违规)", fac)
}
}
swapped := &kernelboot.Plan{Layers: [][]kernelboot.Module{
plan.Layers[0],
plan.Layers[2], // ← 原本在第 3 位:knowledge-store / llm / text-memory
plan.Layers[1], // ← 原本在第 2 位:lua / memory / onnx-parser / …
}}
// 只保留到违规发生处(第 1 层)就够:守卫在 Init 之前就失败。
swapped.Layers = swapped.Layers[:2]
ks := emptyState(t)
_, err := kernelboot.Run(swapped, kernelboot.RunOptions{State: ks})
if err == nil {
t.Fatalf("★ 层顺序被改坏(%v 排到了 %v 之前)却装配成功 —— 校验没生效",
layerNames(plan.Layers[2]), layerNames(plan.Layers[1]))
}
var dnr *kernelboot.DependencyNotReadyError
if !errors.As(err, &dnr) {
t.Fatalf("★ 期望 *DependencyNotReadyError(可读报错),实际是 %T: %v",
err, err)
}
// ② 指名道姓:llm 缺 lua、text-memory 缺 memory/onnx、knowledge-store 缺迁移标记。
want := map[string][]string{
"llm": {kernelmod.FacLua},
"text-memory": {kernelmod.FacMemory, kernelmod.FacONNX},
"knowledge-store": {kernelmod.FacMigrated},
}
for mod, facs := range want {
got, ok := dnr.Missing[mod]
if !ok {
t.Fatalf("★ 报错没指名模块 %s 缺依赖。实际缺失表: %v", mod, dnr.Missing)
}
if strings.Join(got, ",") != strings.Join(facs, ",") {
t.Errorf("★ 模块 %s 缺失设施报错不准:期望 %v,实际 %v", mod, facs, got)
}
}
// 报错文本必须能直接照着修(模块名 + 设施名 + 层号都在里面)。
for _, wantSub := range []string{"第 1 层", "llm", kernelmod.FacLua, "缺设施"} {
if !strings.Contains(err.Error(), wantSub) {
t.Errorf("★ 报错文本缺少 %q,无法据此定位(原文: %s)", wantSub, err.Error())
}
}
// ③ 违规发生在装配之前:只跑完了 L0(config / kernel-api)。
if strings.Join(dnr.Produced, ",") != kernelmod.FacConfig+","+kernelmod.FacKernelIO {
t.Errorf("★ 守卫失败时\"已产出事实\"应为 L0 的两个设施,实际 %v", dnr.Produced)
}
if ks.LuaVM != nil {
t.Error("★ 守卫失败后 ks.LuaVM 非 nil —— 说明 lua 模块被跑过了,与\"装配前校验\"矛盾")
}
if dnr.Layer != 1 {
t.Errorf("★ 报错层号应为 1,实际 %d", dnr.Layer)
}
}
// ★★★ 判据 C(变异证明:删掉首层 = 所有消费者都缺依赖):
//
// 与判据 B 不同的问题形态 —— 不是"顺序颠倒",而是"提供者整层消失"
// (旧图/新代码混装、或有人误删一层)。此时第 0 层全部模块缺 config/kernel-io,
// 且**一个 Init 都不该跑**。
func TestRunRejectsMissingProvidingLayer(t *testing.T) {
plan := realPlan(t)
// 删掉第 0 层,其余层原样前移。
cut := &kernelboot.Plan{Layers: append([][]kernelboot.Module{}, plan.Layers[1:]...)}
ks := emptyState(t)
_, err := kernelboot.Run(cut, kernelboot.RunOptions{State: ks})
if err == nil {
t.Fatal("★ 提供者整层消失却装配成功")
}
var dnr *kernelboot.DependencyNotReadyError
if !errors.As(err, &dnr) {
t.Fatalf("★ 期望 *DependencyNotReadyError,实际 %T: %v", err, err)
}
if dnr.Layer != 0 {
t.Errorf("★ 应在第 0 层就拒绝(该层没有任何前置产出),实际报第 %d 层", dnr.Layer)
}
if len(dnr.Produced) != 0 {
t.Errorf("★ 第 0 层之前不该有任何产出,实际 %v", dnr.Produced)
}
// 原第 1 层的模块全部缺 config 或 kernel-io(每个模块至少要一个)。
if len(dnr.Missing) < 5 {
t.Fatalf("★ 只报出 %d 个模块缺依赖(原第 1 层有 %d 个)—— 校验漏报",
len(dnr.Missing), len(plan.Layers[1]))
}
for mod, facs := range dnr.Missing {
ok := false
for _, f := range facs {
if f == kernelmod.FacConfig || f == kernelmod.FacKernelIO {
ok = true
}
}
if !ok {
t.Errorf("★ 模块 %s 被报缺 %v,但它缺的应是 config/kernel-io(整层消失)", mod, facs)
}
}
// 一个模块都不该跑过:IO / EventBus 是 kernel-api 的产物,应仍为 nil。
if ks.IO != nil || ks.EventBus != nil {
t.Error("★ 第 0 层被判缺依赖后仍有模块被执行(IO/EventBus 非 nil)")
}
}

View File

@ -125,8 +125,8 @@ func (r *Registry) ensureProcHost() (*proc.Host, error) {
r.procHost = host
// 事件环适配层:Bus 发布 → 写 EvtRing slot → eventfd 通知子进程
if r.evBus != nil {
er := NewEventRing(host.EvtRing(), int(host.Evtfd().Fd()), r.evBus)
if bus := r.eventBusOrNil(); bus != nil {
er := NewEventRing(host.EvtRing(), int(host.Evtfd().Fd()), bus)
host.SetEvtSubscriber(er)
log.Printf("[plugin] 事件环已创建(Bus → EvtRing → eventfd)")
}
@ -183,8 +183,8 @@ func (r *Registry) onProcCrash(name string, err error) {
r.mu.Unlock()
// 2) 事件通知(webui/诊断插件可订阅)。
if r.evBus != nil {
r.evBus.Publish(&events.Event{
if bus := r.eventBusOrNil(); bus != nil {
bus.Publish(&events.Event{
Type: events.EventSystem,
Source: "plugin",
Payload: map[string]interface{}{

View File

@ -218,18 +218,66 @@ func NewRegistry() *Registry {
}
}
func (r *Registry) SetIOManager(iom *agentIO.IOManager) { r.iom = iom }
func (r *Registry) SetEventBus(evBus *events.Bus) { r.evBus = evBus }
func (r *Registry) SetMemory(memDB *memory.GraphDB) { r.memDB = memDB }
func (r *Registry) SetTextMemory(tm *text.Memory) { r.textMem = tm }
func (r *Registry) SetDocStore(ds *doc.Store) { r.docStore = ds }
func (r *Registry) SetKnowledge(ks *knowledge.Store) { r.ks = ks }
func (r *Registry) SetProviderManager(mgr *agentAPI.ProviderManager) { r.mgr = mgr }
func (r *Registry) SetConfigRegistry(cfgReg *internalConfig.ConfigRegistry) { r.cfgReg = cfgReg }
func (r *Registry) SetPluginDir(dir string) { r.plgDir = dir }
func (r *Registry) SetDataDir(dir string) { r.dataDir = dir }
func (r *Registry) SetLuaVM(vm *luaVM.VM) { r.lua = vm }
func (r *Registry) SetBaseAPIKey(key string) { r.baseKey = key }
func (r *Registry) SetIOManager(iom *agentIO.IOManager) {
r.mu.Lock()
defer r.mu.Unlock()
r.iom = iom
}
func (r *Registry) SetEventBus(evBus *events.Bus) {
r.mu.Lock()
defer r.mu.Unlock()
r.evBus = evBus
}
func (r *Registry) SetMemory(memDB *memory.GraphDB) {
r.mu.Lock()
defer r.mu.Unlock()
r.memDB = memDB
}
func (r *Registry) SetTextMemory(tm *text.Memory) {
r.mu.Lock()
defer r.mu.Unlock()
r.textMem = tm
}
func (r *Registry) SetDocStore(ds *doc.Store) {
r.mu.Lock()
defer r.mu.Unlock()
r.docStore = ds
}
func (r *Registry) SetKnowledge(ks *knowledge.Store) {
r.mu.Lock()
defer r.mu.Unlock()
r.ks = ks
}
func (r *Registry) SetProviderManager(mgr *agentAPI.ProviderManager) {
r.mu.Lock()
defer r.mu.Unlock()
r.mgr = mgr
}
func (r *Registry) SetConfigRegistry(cfgReg *internalConfig.ConfigRegistry) {
r.mu.Lock()
defer r.mu.Unlock()
r.cfgReg = cfgReg
}
func (r *Registry) SetPluginDir(dir string) {
r.mu.Lock()
defer r.mu.Unlock()
r.plgDir = dir
}
func (r *Registry) SetDataDir(dir string) {
r.mu.Lock()
defer r.mu.Unlock()
r.dataDir = dir
}
func (r *Registry) SetLuaVM(vm *luaVM.VM) {
r.mu.Lock()
defer r.mu.Unlock()
r.lua = vm
}
func (r *Registry) SetBaseAPIKey(key string) {
r.mu.Lock()
defer r.mu.Unlock()
r.baseKey = key
}
// kernelControlAllowed 决定某个内置插件能否拿到内核重启/关机通道。
//
@ -250,38 +298,95 @@ var kernelControlAllowed = map[string]bool{
// ★ 为何用 setter 而不是构造参数:internal/plugin 不能 import cmd/homed
//
// (那是反向依赖,且 cmd 包不可被 import)。用接口 + setter 打破方向。
func (r *Registry) SetKernelControl(k sdk.KernelControlAPI) { r.kernelCtl = k }
func (r *Registry) SetKernelControl(k sdk.KernelControlAPI) {
r.mu.Lock()
defer r.mu.Unlock()
r.kernelCtl = k
}
// kernelControlFor 按插件名决定是否授予内核控制通道。
//
// 未在白名单里的插件拿到 nil —— SDK 侧 KernelControl() 返回 nil,
// 调用方(插件自己)必须判 nil 并如实告知用户"当前环境不支持"。
func (r *Registry) kernelControlFor(name string) sdk.KernelControlAPI {
if r.kernelCtl == nil || !kernelControlAllowed[name] {
return nil
}
return r.kernelCtl
r.mu.RLock()
ctl := r.kernelCtl
r.mu.RUnlock()
return kernelControlForCtl(ctl, name)
}
func (r *Registry) SetToolRegistrar(fn sdk.ToolRegistrar) { r.regTool = fn }
func (r *Registry) SetStageRegistrar(fn sdk.StageRegistrar) { r.regStage = fn }
func (r *Registry) SetAPIRegistrar(fn sdk.APIRegistrar) { r.regAPI = fn }
func (r *Registry) SetToolCleaner(tc PluginToolCleaner) { r.toolCleaner = tc }
func (r *Registry) SetStatusProvider(sp sdk.StatusAPI) { r.status = sp }
func (r *Registry) SetSupervisor(sup sdk.SupervisorAPI) { r.sup = sup }
func (r *Registry) SetTracker(trk *tracker.Tracker) { r.trk = trk }
func (r *Registry) SetConfig(cfg *types.Config) { r.cfg = cfg }
func (r *Registry) SetStageHost(sh sdk.ToolSource) { r.stageHost = sh }
// kernelControlForCtl 是 kernelControlFor 的**无锁内核**:给定已快照的 ctl 决定授权。
//
// 拆出来的原因:buildSDK 要在**一次** RLock 里取走整组依赖(见 snapshotDeps),
// 不能再回头单独取锁 —— RWMutex 不可重入,持锁再 RLock 在写者排队时会自锁。
func kernelControlForCtl(ctl sdk.KernelControlAPI, name string) sdk.KernelControlAPI {
if ctl == nil || !kernelControlAllowed[name] {
return nil
}
return ctl
}
func (r *Registry) SetToolRegistrar(fn sdk.ToolRegistrar) {
r.mu.Lock()
defer r.mu.Unlock()
r.regTool = fn
}
func (r *Registry) SetStageRegistrar(fn sdk.StageRegistrar) {
r.mu.Lock()
defer r.mu.Unlock()
r.regStage = fn
}
func (r *Registry) SetAPIRegistrar(fn sdk.APIRegistrar) {
r.mu.Lock()
defer r.mu.Unlock()
r.regAPI = fn
}
func (r *Registry) SetToolCleaner(tc PluginToolCleaner) {
r.mu.Lock()
defer r.mu.Unlock()
r.toolCleaner = tc
}
func (r *Registry) SetStatusProvider(sp sdk.StatusAPI) {
r.mu.Lock()
defer r.mu.Unlock()
r.status = sp
}
func (r *Registry) SetSupervisor(sup sdk.SupervisorAPI) {
r.mu.Lock()
defer r.mu.Unlock()
r.sup = sup
}
func (r *Registry) SetTracker(trk *tracker.Tracker) {
r.mu.Lock()
defer r.mu.Unlock()
r.trk = trk
}
func (r *Registry) SetConfig(cfg *types.Config) {
r.mu.Lock()
defer r.mu.Unlock()
r.cfg = cfg
}
func (r *Registry) SetStageHost(sh sdk.ToolSource) {
r.mu.Lock()
defer r.mu.Unlock()
r.stageHost = sh
}
// SetDeviceAuthQuery 注入"设备是否已授权"的查询(D4)。
//
// 由 bootstrap 在 agent 构造完成后调用。注入后,ToolAPI.CanUse 才会
// 对未授权设备返回 false —— 此前 ToolAPI 路径**完全不过授权闸**。
func (r *Registry) SetDeviceAuthQuery(fn func(deviceID string) bool) {
r.mu.Lock()
r.deviceAuth = fn
r.mu.Unlock()
sdk.SetDeviceAuthQuery(fn)
}
func (r *Registry) SetIndexer(idx *memory.Indexer) { r.idx = idx }
func (r *Registry) SetIndexer(idx *memory.Indexer) {
r.mu.Lock()
defer r.mu.Unlock()
r.idx = idx
}
// SetLoadAllowlist 限制 Load 仅装载指定插件名(failback 受限启动用)。
// 空/未设置 = 装载全部。违反白名单的插件(含已注册工厂)一律跳过。
@ -334,45 +439,168 @@ func (d *channelDevice) Execute(tool string, args map[string]interface{}) (inter
}
func (d *channelDevice) ChannelDef() agentIO.ChannelDef { return d.chDef }
func (r *Registry) buildSDK(name string) *sdk.PluginSDK {
// 插件自己的安装目录:rawfile 资源与可写工作目录都挂在它下面。
pluginDir := filepath.Join(r.plgDir, name)
// sdkDeps 是 buildSDK 所需依赖的**一致性快照**(KERNEL-REGISTRY-SETLOCK)。
//
// 为什么要有这层:这批字段由 Set* 在启动图里注入,读侧若逐个裸读
// (原来的写法),每一处都是独立的竞争点;而且逐个"读一次锁一次"会让
// 同一次 buildSDK 里拿到**互相不一致**的组合(比如 lua 已是新的、cfg 还是旧的)。
// 一次 RLock 取齐既消灭竞争,也保证同一次 SDK 装配看到的是同一个状态。
type sdkDeps struct {
plgDir string
dataDir string
cfgReg *internalConfig.ConfigRegistry
iom *agentIO.IOManager
evBus *events.Bus
memDB *memory.GraphDB
textMem *text.Memory
docStore *doc.Store
ks *knowledge.Store
mgr *agentAPI.ProviderManager
lua *luaVM.VM
baseKey string
regTool sdk.ToolRegistrar
regStage sdk.StageRegistrar
regAPI sdk.APIRegistrar
status sdk.StatusAPI
sup sdk.SupervisorAPI
trk *tracker.Tracker
cfg *types.Config
stageHost sdk.ToolSource
idx *memory.Indexer
kernelCtl sdk.KernelControlAPI
}
sett := sdk.NewSettings(name, r.cfgReg)
// snapshotDeps 在一次 RLock 内取走 buildSDK 需要的全部依赖。
func (r *Registry) snapshotDeps() sdkDeps {
r.mu.RLock()
defer r.mu.RUnlock()
return sdkDeps{
plgDir: r.plgDir,
dataDir: r.dataDir,
cfgReg: r.cfgReg,
iom: r.iom,
evBus: r.evBus,
memDB: r.memDB,
textMem: r.textMem,
docStore: r.docStore,
ks: r.ks,
mgr: r.mgr,
lua: r.lua,
baseKey: r.baseKey,
regTool: r.regTool,
regStage: r.regStage,
regAPI: r.regAPI,
status: r.status,
sup: r.sup,
trk: r.trk,
cfg: r.cfg,
stageHost: r.stageHost,
idx: r.idx,
kernelCtl: r.kernelCtl,
}
}
// ioManagerOrNil 返回当前 IOManager 快照(无则 nil)。
//
// 给 buildSDK 里的**闭包**用:闭包在插件 Start 期间才被调用,那时不能持锁
// (会跨用户代码),所以每次都取一次新快照 —— 语义与原来的 r.iom 裸读一致,
// 只是变成有序读。
func (r *Registry) ioManagerOrNil() *agentIO.IOManager {
r.mu.RLock()
iom := r.iom
r.mu.RUnlock()
return iom
}
// pluginDirOrEmpty / cfgRegistryOrNil / toolCleanerOrNil / stageHostOrNil
// 是其余读点的同款访问器。集中在这里,避免各处自己拼锁走样。
func (r *Registry) pluginDirOrEmpty() string {
r.mu.RLock()
dir := r.plgDir
r.mu.RUnlock()
return dir
}
func (r *Registry) cfgRegistryOrNil() *internalConfig.ConfigRegistry {
r.mu.RLock()
cfgReg := r.cfgReg
r.mu.RUnlock()
return cfgReg
}
func (r *Registry) toolCleanerOrNil() PluginToolCleaner {
r.mu.RLock()
tc := r.toolCleaner
r.mu.RUnlock()
return tc
}
func (r *Registry) eventBusOrNil() *events.Bus {
r.mu.RLock()
bus := r.evBus
r.mu.RUnlock()
return bus
}
func (r *Registry) stageHostOrNil() sdk.ToolSource {
r.mu.RLock()
sh := r.stageHost
r.mu.RUnlock()
return sh
}
func (r *Registry) buildSDK(name string) *sdk.PluginSDK {
// ★ 依赖快照(2026-10-24,KERNEL-REGISTRY-SETLOCK)。
//
// 为什么必须**一次取齐**而不是用 r.xxx 逐个裸读:
// 这批字段由 Set* 在启动图第 4 层注入,而 buildSDK 在插件加载时同理并发执行;
// 每个 r.xxx 各自裸读 = 每个都是一处数据竞争(实测 4% 概率报 7 条)。
//
// 为什么不在整个 buildSDK 外面套一把 RLock:下面的闭包(regOutput 等)
// 只在**插件 Start 期间**才被调用,而 Start 本身可能在持锁区外跑很久;
// 持着 registry 读锁跨用户代码要冒死锁风险(插件回调进内核注册面)。
// 所以取到快照就立刻放锁,闭包里再用访问器晚绑定。
deps := r.snapshotDeps()
// 插件自己的安装目录:rawfile 资源与可写工作目录都挂在它下面。
pluginDir := filepath.Join(deps.plgDir, name)
sett := sdk.NewSettings(name, deps.cfgReg)
if sd, ok := sett.(interface{ SetDataDir(string) }); ok {
// 插件专属数据目录:<data>/plugin_data/<name>,保证存在
dir := filepath.Join(r.dataDir, "plugin_data", name)
dir := filepath.Join(deps.dataDir, "plugin_data", name)
os.MkdirAll(dir, 0755)
sd.SetDataDir(dir)
}
regTool := r.regTool
regTool := deps.regTool
if regTool == nil {
regTool = func(toolName string, def sdk.ToolDef, handler sdk.ToolHandler) error {
return nil
}
}
regStage := r.regStage
regStage := deps.regStage
if regStage == nil {
regStage = func(stage sdk.Stage, handler sdk.StageHandler) {}
}
// stage handler 注册时带上归属插件名,使卸载/崩溃时能成组摘除。
// StageHost 实现了 RegisterStageFor;其他实现(测试替身)退回无归属注册。
if h, ok := r.stageRegistrarFor(); ok {
if h, ok := stageRegistrarForSource(deps.stageHost); ok {
regStage = func(stage sdk.Stage, handler sdk.StageHandler) {
h(name, stage, handler)
}
}
regAPI := r.regAPI
regAPI := deps.regAPI
if regAPI == nil {
regAPI = func(name string) error { return nil }
}
regOutput := func(chName string, caps int, desc string, def sdk.ChannelDef, handler sdk.ToolHandler) error {
if r.iom == nil {
iom := r.ioManagerOrNil()
if iom == nil {
return nil
}
if err := r.iom.RegisterDevice(&channelDevice{
if err := iom.RegisterDevice(&channelDevice{
name: chName,
caps: agentIO.OutputCapability(caps),
desc: desc,
@ -392,43 +620,45 @@ func (r *Registry) buildSDK(name string) *sdk.PluginSDK {
// 按此刻的状态判会对它误报(实测:把 qq 报成"只声明了输出通道")。
// 真正该问的问题是"插件 Start 结束后,这个出站通道有没有对应的入站声明" ——
// 那在 load 完成后统一判(见 warnOutputOnlyChannels)。
if _, ok := r.iom.LookupInputChannel(chName); !ok {
_ = r.iom.RegisterInputChannelFrom(name, chName, agentIO.ChannelDef(def))
if _, ok := iom.LookupInputChannel(chName); !ok {
_ = iom.RegisterInputChannelFrom(name, chName, agentIO.ChannelDef(def))
}
r.noteChannel(name, chName, true)
return nil
}
regOutputUnreg := func(chName string) error {
if r.iom == nil {
iom := r.ioManagerOrNil()
if iom == nil {
return nil
}
r.iom.UnregisterDevice(chName)
iom.UnregisterDevice(chName)
r.forgetChannel(name, chName)
return nil
}
regInput := func(chName string, def sdk.ChannelDef) error {
if r.iom == nil {
iom := r.ioManagerOrNil()
if iom == nil {
return nil
}
// inputch 是最基本的输入路由单位:登记**归属插件**,便于父 agent 看清
// "哪个插件的哪个 inputch 划给了谁"(一个插件可注册多个 inputch)。
_ = r.iom.RegisterInputChannelFrom(name, chName, agentIO.ChannelDef(def))
_ = iom.RegisterInputChannelFrom(name, chName, agentIO.ChannelDef(def))
r.noteChannel(name, chName, false)
return nil
}
return sdk.New(name, sdk.SDKConfig{
IOManager: r.iom,
EventBus: r.evBus,
IOManager: deps.iom,
EventBus: deps.evBus,
// 块自带负载(媒体字节在块的 Data),不再需要独立的媒体存储句柄。
// 插件名仅用于日志溯源(哪个插件写的块)。
Memory: sdk.NewGraphMemoryWithPlugin(name, r.memDB),
TextMemory: sdk.NewTextMemoryWithPlugin(name, r.textMem),
DocMemory: sdk.NewDocMemoryWithPlugin(name, r.docStore),
Knowledge: sdk.NewKnowledge(r.ks),
LLM: sdk.NewLLM(r.mgr, r.cfgReg, r.lua, r.baseKey),
Memory: sdk.NewGraphMemoryWithPlugin(name, deps.memDB),
TextMemory: sdk.NewTextMemoryWithPlugin(name, deps.textMem),
DocMemory: sdk.NewDocMemoryWithPlugin(name, deps.docStore),
Knowledge: sdk.NewKnowledge(deps.ks),
LLM: sdk.NewLLM(deps.mgr, deps.cfgReg, deps.lua, deps.baseKey),
Settings: sett,
// ★ 变量库:桥接到进程级 security.Global()(**不另建存储**)。
// 内核未启用安全模块时它 Available()==false(不是 nil)。
@ -444,21 +674,21 @@ func (r *Registry) buildSDK(name string) *sdk.PluginSDK {
RegOutputUnreg: regOutputUnreg,
RegInput: regInput,
PluginMgr: r,
KernelControl: r.kernelControlFor(name),
KernelControl: kernelControlForCtl(deps.kernelCtl, name),
Status: r.status,
Supervisor: r.sup,
Adapter: sdk.NewAdapter(r.lua),
Tracker: r.trk,
Config: sdk.NewConfig(r.cfg),
Tool: sdk.NewTool(r.stageHost, r.iom),
Indexer: sdk.NewIndexer(r.idx),
Status: deps.status,
Supervisor: deps.sup,
Adapter: sdk.NewAdapter(deps.lua),
Tracker: deps.trk,
Config: sdk.NewConfig(deps.cfg),
Tool: sdk.NewTool(deps.stageHost, deps.iom),
Indexer: sdk.NewIndexer(deps.idx),
})
}
func (r *Registry) Load(dir string) error {
if dir == "" {
dir = r.plgDir
dir = r.pluginDirOrEmpty()
}
os.MkdirAll(dir, 0755)
@ -549,10 +779,11 @@ func (r *Registry) Load(dir string) error {
}
func (r *Registry) isDisabled(name string) bool {
if r.cfgReg == nil {
cfgReg := r.cfgRegistryOrNil()
if cfgReg == nil {
return false
}
return r.cfgReg.IsPluginDisabled(name)
return cfgReg.IsPluginDisabled(name)
}
// pluginEntryHash 计算插件入口文件的 SHA256,用于增量重载对比。
@ -670,10 +901,18 @@ func (r *Registry) runStopHandlers(name string) {
// 以 sdk.ToolSource 存在 r.stageHost 上。能取到 RegisterStageFor 时就直接用它,
// 否则退回无归属注册(测试替身、旧集成方)。
func (r *Registry) stageRegistrarFor() (func(plugin string, stage sdk.Stage, handler sdk.StageHandler), bool) {
if r.stageHost == nil {
r.mu.RLock()
sh := r.stageHost
r.mu.RUnlock()
return stageRegistrarForSource(sh)
}
// stageRegistrarForSource 是 stageRegistrarFor 的无锁内核(同样为了快照后复用)。
func stageRegistrarForSource(sh sdk.ToolSource) (func(plugin string, stage sdk.Stage, handler sdk.StageHandler), bool) {
if sh == nil {
return nil, false
}
if h, ok := r.stageHost.(interface {
if h, ok := sh.(interface {
RegisterStageFor(plugin string, stage sdk.Stage, handler sdk.StageHandler)
}); ok {
return h.RegisterStageFor, true
@ -756,16 +995,20 @@ func (r *Registry) releasePluginChannels(plugin string) []string {
set := r.pluginChannels[plugin]
delete(r.pluginChannels, plugin)
r.channelsMu.Unlock()
if set == nil || r.iom == nil {
if set == nil {
return nil
}
iom := r.ioManagerOrNil()
if iom == nil {
return nil
}
var released []string
for ch := range set.outputs {
r.iom.UnregisterDevice(ch)
iom.UnregisterDevice(ch)
released = append(released, ch)
}
for ch := range set.inputs {
r.iom.UnregisterInputChannel(ch)
iom.UnregisterInputChannel(ch)
if !set.outputs[ch] {
released = append(released, ch)
}
@ -780,9 +1023,9 @@ func (r *Registry) releasePluginChannels(plugin string) []string {
// StopAndUnload)只调 UnregisterPluginTools,漏了 stage 与通道两项,
// 子进程崩溃路径更是三项都没做。
func (r *Registry) detachPlugin(name string) {
if r.toolCleaner != nil {
r.toolCleaner.UnregisterPluginTools(name)
if sc, ok := r.toolCleaner.(PluginStageCleaner); ok {
if tc := r.toolCleanerOrNil(); tc != nil {
tc.UnregisterPluginTools(name)
if sc, ok := tc.(PluginStageCleaner); ok {
if n := sc.UnregisterPluginStages(name); n > 0 {
log.Printf("[plugin] %s: 摘除 %d 个 stage handler", name, n)
}
@ -873,7 +1116,7 @@ func (r *Registry) StopAll() {
func (r *Registry) Reload(dir string) (string, error) {
if dir == "" {
dir = r.plgDir
dir = r.pluginDirOrEmpty()
}
// 增量重载:扫描插件目录,对比入口文件 hash,仅 Stop+重载有变更的插件。
// 未变更插件保持运行,避免 plgreload 触发全量 StopAll+Load 导致所有插件重复加载
@ -949,7 +1192,7 @@ func (r *Registry) LoadOneDir(plgDir, name string) error {
// 所有插件(含声明了依赖的)立刻加载,调度器就无事可做了。
func (r *Registry) LoadSkipping(dir string, skip map[string]bool) error {
if dir == "" {
dir = r.plgDir
dir = r.pluginDirOrEmpty()
}
os.MkdirAll(dir, 0755)
@ -1033,7 +1276,7 @@ func (r *Registry) LoadSkipping(dir string, skip map[string]bool) error {
}
func (r *Registry) ReloadOne(name string) error {
plgDir := filepath.Join(r.plgDir, name)
plgDir := filepath.Join(r.pluginDirOrEmpty(), name)
r.mu.Lock()
var removed sdk.Plugin
@ -1115,13 +1358,13 @@ func (r *Registry) IsDisabled(name string) bool {
}
func (r *Registry) Enable(name string) error {
if r.cfgReg != nil {
r.cfgReg.RemoveDisabledPlugin(name)
if cfgReg := r.cfgRegistryOrNil(); cfgReg != nil {
cfgReg.RemoveDisabledPlugin(name)
}
r.mu.Lock()
delete(r.knownDisabled, name)
r.mu.Unlock()
plgDir := filepath.Join(r.plgDir, name)
plgDir := filepath.Join(r.pluginDirOrEmpty(), name)
if r.loadOne(plgDir, name) {
log.Printf("[plugin] enabled: %s", name)
return nil
@ -1151,8 +1394,8 @@ func (r *Registry) Disable(name string) error {
r.detachPlugin(name)
if r.cfgReg != nil {
r.cfgReg.AddDisabledPlugin(name, "system")
if cfgReg := r.cfgRegistryOrNil(); cfgReg != nil {
cfgReg.AddDisabledPlugin(name, "system")
}
log.Printf("[plugin] disabled: %s", name)
return nil
@ -1197,8 +1440,8 @@ func (r *Registry) pluginInstalled(name string) bool {
if _, ok := globalFactories.Load(name); ok {
return true
}
if r.plgDir != "" {
if fi, err := os.Stat(filepath.Join(r.plgDir, name)); err == nil && fi.IsDir() {
if dir := r.pluginDirOrEmpty(); dir != "" {
if fi, err := os.Stat(filepath.Join(dir, name)); err == nil && fi.IsDir() {
return true
}
}
@ -1208,10 +1451,11 @@ func (r *Registry) pluginInstalled(name string) bool {
func (r *Registry) ListLoadedPlugins() []string { return r.List() }
func (r *Registry) ListDisabledPlugins() []sdk.DisabledPluginInfo {
if r.cfgReg == nil {
cfgReg := r.cfgRegistryOrNil()
if cfgReg == nil {
return nil
}
list, err := r.cfgReg.ListDisabledPlugins()
list, err := cfgReg.ListDisabledPlugins()
if err != nil {
return nil
}
@ -1238,9 +1482,9 @@ func (r *Registry) DisablePlugin(name, by string) error {
return fmt.Errorf("plugin %s not installed", name)
}
// Check not disabling self if running
if r.cfgReg != nil {
if cfgReg := r.cfgRegistryOrNil(); cfgReg != nil {
// If already disabled, no-op
if r.cfgReg.IsPluginDisabled(name) {
if cfgReg.IsPluginDisabled(name) {
return fmt.Errorf("plugin %s already disabled", name)
}
}
@ -1266,8 +1510,8 @@ func (r *Registry) DisablePlugin(name, by string) error {
r.detachPlugin(name)
if r.cfgReg != nil {
r.cfgReg.AddDisabledPlugin(name, by)
if cfgReg := r.cfgRegistryOrNil(); cfgReg != nil {
cfgReg.AddDisabledPlugin(name, by)
}
log.Printf("[plugin] disabled: %s (by %s)", name, by)
return nil
@ -1386,7 +1630,7 @@ func (r *Registry) StartPlugin(name string) error {
return nil
}
plgDir := filepath.Join(r.plgDir, name)
plgDir := filepath.Join(r.pluginDirOrEmpty(), name)
if r.loadOne(plgDir, name) {
if wasStopped {
log.Printf("[plugin] runtime started: %s(已清运行态关闭)", name)
@ -1477,16 +1721,16 @@ func (r *Registry) RemovePlugin(name string) error {
r.mu.Unlock()
r.detachPlugin(name)
if r.cfgReg != nil {
r.cfgReg.RemoveDisabledPlugin(name)
r.cfgReg.RemovePlugin(name)
if cfgReg := r.cfgRegistryOrNil(); cfgReg != nil {
cfgReg.RemoveDisabledPlugin(name)
cfgReg.RemovePlugin(name)
}
r.closeDynamic(removed)
log.Printf("[plugin] removed: %s", name)
return nil
}
func (r *Registry) ReloadPlugins() (string, error) { return r.Reload(r.plgDir) }
func (r *Registry) ReloadPlugins() (string, error) { return r.Reload(r.pluginDirOrEmpty()) }
// PluginRuntime 返回单个插件的运行期状态。
//
@ -1508,8 +1752,8 @@ func (r *Registry) PluginRuntime(name string) (sdk.PluginRuntimeInfo, bool) {
installed := loaded || hasFactory
var dirExists bool
if r.plgDir != "" {
if st, err := os.Stat(filepath.Join(r.plgDir, name)); err == nil && st.IsDir() {
if dir := r.pluginDirOrEmpty(); dir != "" {
if st, err := os.Stat(filepath.Join(dir, name)); err == nil && st.IsDir() {
dirExists = true
installed = true
}
@ -1531,7 +1775,7 @@ func (r *Registry) PluginRuntime(name string) (sdk.PluginRuntimeInfo, bool) {
// auto_start 以清单为准(plugin.json 显式 false 才是按需)。
// 目录不存在(纯内置)时保持缺省 true —— 内置插件本就随内核加载。
if dirExists {
if m, err := ReadManifest(filepath.Join(r.plgDir, name)); err == nil {
if m, err := ReadManifest(filepath.Join(r.pluginDirOrEmpty(), name)); err == nil {
info.AutoStart = m.ShouldAutoStart()
}
}
@ -1540,7 +1784,7 @@ func (r *Registry) PluginRuntime(name string) (sdk.PluginRuntimeInfo, bool) {
case hasFactory:
info.Channel = "builtin"
case dirExists:
info.Channel = detectEntryKind(filepath.Join(r.plgDir, name)).String()
info.Channel = detectEntryKind(filepath.Join(r.pluginDirOrEmpty(), name)).String()
}
// 子进程插件报真实 PID 与存活;其余形态与 Loaded 同值(无独立进程)。
@ -1555,8 +1799,8 @@ func (r *Registry) PluginRuntime(name string) (sdk.PluginRuntimeInfo, bool) {
info.Alive = loaded
}
if r.stageHost != nil {
for _, def := range r.stageHost.GetToolDefs() {
if sh := r.stageHostOrNil(); sh != nil {
for _, def := range sh.GetToolDefs() {
if def.Plugin == name {
info.Tools = append(info.Tools, def.Name)
}
@ -1601,8 +1845,8 @@ func (r *Registry) ListKnown() []string {
}
r.mu.RUnlock()
if r.plgDir != "" {
entries, _ := os.ReadDir(r.plgDir)
if dir := r.pluginDirOrEmpty(); dir != "" {
entries, _ := os.ReadDir(dir)
for _, e := range entries {
if e.IsDir() {
known[e.Name()] = true
@ -1639,7 +1883,7 @@ func (r *Registry) PluginMetas() map[string]sdk.PluginMeta {
}
func (r *Registry) PluginDir() string {
return r.plgDir
return r.pluginDirOrEmpty()
}
func (r *Registry) tryDynamic(plgDir, name string, config map[string]interface{}) (sdk.Plugin, error) {

View File

@ -0,0 +1,260 @@
package plugin
// KERNEL-REGISTRY-SETLOCK(2026-10-24):Registry Set* 注入路径的**写-写竞争**判据。
//
// ## 为什么必须有这条判据
//
// Registry 上那批 Set*(SetLuaVM / SetBaseAPIKey / SetStageHost / SetIndexer …)
// 长期是单行无锁直写:`func (r *Registry) SetLuaVM(vm *luaVM.VM) { r.lua = vm }`。
// 结构体里明明有 r.mu 这个 RWMutex,但这批 setter 一次都没取过锁。
//
// 平时看不出来,因为注入通常发生在启动早期。内核模块化之后就不成立了:
// **两个 goroutine 会同时注入同一批字段** ——
//
// internal/kernelmod/agent/mod.go wirePluginSDK → SetLuaVM/SetBaseAPIKey/SetStageHost/…
// internal/kernelmod/pluginhost/mod.go injectBaseDeps → SetLuaVM/SetBaseAPIKey/SetStageHost/…
//
// 两者同属启动图第 4 层、并行执行,于是 r.lua / r.baseKey / r.stageHost / r.idx
// 被两个 goroutine 同时写。Go 内存模型下这是 data race;interface 字段尤其危险
// (type 与 data 两字不是同一轮写入 ⇒ 读到撕裂值,调用时直接崩)。
//
// ## 两条判据,一快一准
//
// 1. TestSettersConcurrentWriteRace —— **运行时**判据:并发 Set + 并发读,
// -race 下未加锁必然报 DATA RACE。这是正面证据(真跑真竞争)。
// 2. TestRegistrySettersTakeWriteLock —— **源码级**判据:逐个 setter 检查
// 函数体内必须出现 r.mu.Lock()。race 是概率性的,这条把"有人把锁删了"
// 这类回退 100% 挡在门口(变异实验里它是确定性命中的那一条)。
//
// ★ 两条都必须"活的":把任一 setter 的锁去掉(换回直写),两条都要报红。
// ★ 本文件只依赖 Registry 的公开 setter 与 r.mu 本身,不引用任何内部访问器 ——
// 因此在**未修复**与**已修复**两版 registry.go 上都能编译,
// 才可能拿它当"修前红 / 修后绿"的**同一条**判据。
import (
"go/ast"
"go/parser"
"go/token"
"os"
"runtime"
"sync"
"testing"
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
"github.com/JianFeeeee/HologramAgent/internal/lua"
"github.com/JianFeeeee/HologramAgent/internal/memory"
sdk "github.com/JianFeeeee/HologramAgent/internal/sdk"
)
// setlockRounds 是每个注入 goroutine 的轮数。
//
// 取值依据:单轮 6 次写 × 2 个写 goroutine,未加锁代码上 -race 稳定命中
// (实测连续多次运行均报红,见交付证据);再往上只是拖长测试。
const setlockRounds = 300
// TestSettersConcurrentWriteRace 钉住 setter 之间的写-写竞争。
func TestSettersConcurrentWriteRace(t *testing.T) {
r := NewRegistry()
// 两组 goroutine 用**不同的值**写同一批字段:
// 写相同值容易被优化掉,不同值才能让 race detector 看到
// "读到组合了两次写入的状态"。
vmA, vmB := &lua.VM{}, &lua.VM{}
shA := &raceToolSource{tag: "A"}
shB := &raceToolSource{tag: "B"}
idxA := &memory.Indexer{}
idxB := &memory.Indexer{}
// IOManager 只作为被写入/读回的句柄,判据不调其方法。
iomA, iomB := &agentIO.IOManager{}, &agentIO.IOManager{}
// ★ 目录只取一次:放在循环里 t.TempDir() 会造出上万个临时目录。
const dirA, dirB = "dir-A", "dir-B"
inject := func(vm *lua.VM, key, dir string, sh sdk.ToolSource, idx *memory.Indexer, iom *agentIO.IOManager) {
for i := 0; i < setlockRounds; i++ {
// 与 wirePluginSDK / injectBaseDeps 交叠的核心项
r.SetLuaVM(vm)
r.SetBaseAPIKey(key)
r.SetStageHost(sh)
r.SetIndexer(idx)
// 同一批装配里相邻的另外两项
r.SetIOManager(iom)
r.SetPluginDir(dir)
}
}
var writers sync.WaitGroup
writers.Add(2)
go func() { defer writers.Done(); inject(vmA, "key-A", dirA, shA, idxA, iomA) }()
go func() { defer writers.Done(); inject(vmB, "key-B", dirB, shB, idxB, iomB) }()
// 读侧并行:真实内核里 buildSDK 读这些字段与注入是交织的。
// 读侧持 RLock(本次修复不动读侧)—— 同时验证"没有把并发读改成串行"。
stop := make(chan struct{})
var readers sync.WaitGroup
readers.Add(1)
go func() {
defer readers.Done()
for {
select {
case <-stop:
return
default:
vm, key, sh, idx := r.injectedSnapshot()
// 必须真的用掉读到的值,否则编译器可能把读优化掉,
// race detector 就看不到这次读。
if vm == nil && key != "" && sh != nil && idx == nil {
t.Error("setlock: 读到不可能的注入组合(读侧失去意义)")
return
}
}
}
}()
writers.Wait()
close(stop)
readers.Wait()
}
// injectedSnapshot 以 RLock 一次性读齐 4 个注入字段(模拟 buildSDK 读侧)。
func (r *Registry) injectedSnapshot() (*lua.VM, string, sdk.ToolSource, *memory.Indexer) {
r.mu.RLock()
defer r.mu.RUnlock()
return r.lua, r.baseKey, r.stageHost, r.idx
}
// raceToolSource 是 sdk.ToolSource 的最小实现(判据里只需要个非 nil 值)。
type raceToolSource struct{ tag string }
func (s *raceToolSource) GetToolDefs() []sdk.ToolDef { return nil }
func (s *raceToolSource) ToolDef(string) *sdk.ToolDef { return nil }
func (s *raceToolSource) ExecuteTool(string, map[string]interface{}) (interface{}, error) {
return s.tag, nil
}
// registrySetterNames 是需要持写锁的 setter 清单(与 registry.go 对应)。
// 新增 Set* 时必须同步登记 —— 源码判据按这份清单咬。
var registrySetterNames = []string{
"SetIOManager", "SetEventBus", "SetMemory", "SetTextMemory",
"SetDocStore", "SetKnowledge", "SetProviderManager", "SetConfigRegistry",
"SetPluginDir", "SetDataDir", "SetLuaVM", "SetBaseAPIKey",
"SetKernelControl", "SetToolRegistrar", "SetStageRegistrar", "SetAPIRegistrar",
"SetToolCleaner", "SetStatusProvider", "SetSupervisor", "SetTracker",
"SetConfig", "SetStageHost", "SetDeviceAuthQuery", "SetIndexer",
}
// TestRegistrySettersTakeWriteLock 是源码级判据:每个 Registry.Set* 函数体里
// 必须出现 r.mu.Lock()。
//
// 为什么需要它(与运行时判据并存):
//
// race detector 依赖"这一次运行恰好交错到了",是概率性的。
// 而"某人把锁删了"这类回退必须 100% 被挡在 CI 门口 —— 源码判据确定性命中。
func TestRegistrySettersTakeWriteLock(t *testing.T) {
_, thisFile, _, _ := runtime.Caller(0)
src, err := os.ReadFile(thisFile[:len(thisFile)-len("registry_setlock_test.go")] + "registry.go")
if err != nil {
t.Fatalf("读 registry.go 失败: %v", err)
}
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "registry.go", src, 0)
if err != nil {
t.Fatalf("解析 registry.go 失败: %v", err)
}
locked := map[string]bool{}
for _, decl := range f.Decls {
fd, ok := decl.(*ast.FuncDecl)
if !ok || fd.Recv == nil || fd.Name == nil || fd.Body == nil {
continue
}
name := fd.Name.Name
if len(name) < 4 || name[:3] != "Set" {
continue
}
// 函数体内是否出现 r.mu.Lock()。
//
// ★ 形状注意:`r.mu.Lock()` 的 CallExpr.Fun 是
// SelectorExpr{ Sel: "Lock", X: SelectorExpr{ Sel: "mu", X: Ident{"r"} } }
// 不是 X 直接为 Ident —— 按 x.Name == "r" 判会漏掉**全部**加锁的 setter,
// 判据变成"恒报红",等于没判(本判据第一版就踩了这个坑)。
found := false
ast.Inspect(fd.Body, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok || sel.Sel == nil || sel.Sel.Name != "Lock" {
return true
}
mu, ok := sel.X.(*ast.SelectorExpr)
if !ok || mu.Sel == nil || mu.Sel.Name != "mu" {
return true
}
if id, ok := mu.X.(*ast.Ident); ok && id.Name == "r" {
found = true
}
return true
})
if found {
locked[name] = true
}
}
for _, want := range registrySetterNames {
if !locked[want] {
t.Errorf("Registry.%s 未取写锁(r.mu.Lock)—— "+
"这会重新引入启动图第 4 层并行注入的 DATA RACE(KERNEL-REGISTRY-SETLOCK)。"+
"修法:函数体开头 r.mu.Lock(); defer r.mu.Unlock()", want)
}
}
}
// TestBuildSDKConcurrentWithSetters 钉住**真实读路径**与 Set* 的读写竞争。
//
// 为什么需要它(前两条判据覆盖不到的地方):
//
// TestSettersConcurrentWriteRace 的读侧用的是本文件自己写的 injectedSnapshot
// ——它**自带 RLock**,所以只证明了"setter 之间不再互撞",
// 完全测不到 registry.go 里 buildSDK / snapshotDeps 那条真实读路径。
// 把 snapshotDeps 的 RLock 删掉,前两条判据仍会全绿(实测过)——
// 那就是判据缺口:read 侧的回退没人管。
//
// 本判据走 buildSDK(插件加载时真实调用的函数),setter 并发写同一批字段:
// 读侧若失去 RLock,-race 必报,且报告栈会直接指向 registry.go 的读点。
func TestBuildSDKConcurrentWithSetters(t *testing.T) {
r := NewRegistry()
r.SetPluginDir(t.TempDir())
r.SetDataDir(t.TempDir())
const rounds = 200
var wg sync.WaitGroup
// 写侧:模拟 agent 与 pluginhost 两个模块并行注入。
wg.Add(2)
go func() {
defer wg.Done()
for i := 0; i < rounds; i++ {
r.SetLuaVM(nil)
r.SetConfig(nil)
}
}()
go func() {
defer wg.Done()
for i := 0; i < rounds; i++ {
r.SetStageHost(nil)
r.SetIndexer(nil)
}
}()
// 读侧:走真实路径 buildSDK(内部 snapshotDeps 取依赖快照)。
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < rounds; i++ {
_ = r.buildSDK("setlock-probe")
}
}()
wg.Wait()
}