diff --git a/.github/scripts/check-ci-gates.py b/.github/scripts/check-ci-gates.py new file mode 100755 index 00000000..8016a296 --- /dev/null +++ b/.github/scripts/check-ci-gates.py @@ -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 步骤语法") diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cd6789b0..fefd3838 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 }}" diff --git a/docs/zh/kernel-modularization-design.md b/docs/zh/kernel-modularization-design.md index 9c4a166b..2f814e06 100644 --- a/docs/zh/kernel-modularization-design.md +++ b/docs/zh/kernel-modularization-design.md @@ -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 步严格串行) diff --git a/internal/agent/core/scheduler_stress_test.go b/internal/agent/core/scheduler_stress_test.go index d6803843..72a973a2 100644 --- a/internal/agent/core/scheduler_stress_test.go +++ b/internal/agent/core/scheduler_stress_test.go @@ -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) diff --git a/internal/kernelboot/kernelboot.go b/internal/kernelboot/kernelboot.go index e1f5084c..feb68c69 100644 --- a/internal/kernelboot/kernelboot.go +++ b/internal/kernelboot/kernelboot.go @@ -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 { diff --git a/internal/kernelboot/kernelstate.go b/internal/kernelboot/kernelstate.go index 3460682e..0fe435ce 100644 --- a/internal/kernelboot/kernelstate.go +++ b/internal/kernelboot/kernelstate.go @@ -80,7 +80,10 @@ type MemoryBundle struct { // · 少一层间接 ⇒ 少一处可能出错的地方 // // 顺序正确性由**声明**驱动:`Build()` 校验缺依赖 / 环 / 重名, -// 判据断言关键顺序(见 internal/kernelboot/modules 的判据)。 +// `Run()` 在执行每一层之前再核一次"实际已产出的设施"是否涵盖本层依赖 +// (声明对、但计划层顺序被改坏时也能拦住,见 DependencyNotReadyError)。 +// 判据断言关键顺序(见 internal/kernelmod/all/bootgraph_test.go 的 +// TestLayerTopologyIsRespected:用真实模块登记表装配整机,逐层断言层间有序)。 // // ## ★ 并发 // diff --git a/internal/kernelboot/modules/registry.go b/internal/kernelboot/modules/registry.go deleted file mode 100644 index 27b7a99e..00000000 --- a/internal/kernelboot/modules/registry.go +++ /dev/null @@ -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 -} diff --git a/internal/kernelboot/runtimedep_test.go b/internal/kernelboot/runtimedep_test.go new file mode 100644 index 00000000..3ab06d4e --- /dev/null +++ b/internal/kernelboot/runtimedep_test.go @@ -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 没有判别力") + } +} diff --git a/internal/kernelmod/agent/mod.go b/internal/kernelmod/agent/mod.go index 31e3a54a..b2a8984b 100644 --- a/internal/kernelmod/agent/mod.go +++ b/internal/kernelmod/agent/mod.go @@ -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) } diff --git a/internal/kernelmod/all/bootgraph_test.go b/internal/kernelmod/all/bootgraph_test.go new file mode 100644 index 00000000..91d5a110 --- /dev/null +++ b/internal/kernelmod/all/bootgraph_test.go @@ -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 层的前驱全在 首次被提供的层号 + 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]) +} diff --git a/internal/kernelmod/all/runtime_dep_test.go b/internal/kernelmod/all/runtime_dep_test.go new file mode 100644 index 00000000..6f3eeb03 --- /dev/null +++ b/internal/kernelmod/all/runtime_dep_test.go @@ -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)") + } +} diff --git a/internal/plugin/dynamic_proc.go b/internal/plugin/dynamic_proc.go index 96f66969..986a02f0 100644 --- a/internal/plugin/dynamic_proc.go +++ b/internal/plugin/dynamic_proc.go @@ -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{}{ diff --git a/internal/plugin/registry.go b/internal/plugin/registry.go index 8b1d3d83..3b612800 100644 --- a/internal/plugin/registry.go +++ b/internal/plugin/registry.go @@ -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 { // 插件专属数据目录:/plugin_data/,保证存在 - 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) { diff --git a/internal/plugin/registry_setlock_test.go b/internal/plugin/registry_setlock_test.go new file mode 100644 index 00000000..38c3e83e --- /dev/null +++ b/internal/plugin/registry_setlock_test.go @@ -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() +}