feat: 模块化内核(编译期依赖图定序)+ 密钥防火墙 + 插件三态生命周期 (#10)

* feat(kernel): 模块化内核 + 编译期依赖图定序

把单体 init 序列拆成「模块自声明依赖 + 编译期生成依赖图 + 运行期只读图」。

## 为什么

原 cmd/homed/main.go 里 17 步 init 是**手写顺序**,依赖关系只存在于
注释和人脑里。实测踩到的坑(见 docs/zh/kernel-modularization-design.md):
 · nlp.defaultParser 被 initONNXParser 写、被 distiller 读,两者相距 60 行,
   只靠 ticker 10min 才没炸(隐式依赖,无声明)
 · 加一个模块要人肉找"该插在哪",插错的表现是运行期 nil 解引用
 · 关停是 defer ks.CloseAll(),而热更新路径 os.Exit(42) **跳过 defer**
   ⇒ docStore/distiller/textMem 的 flush 全部丢失(每次热更新都发生)

## 怎么改

- internal/kernelboot/:KernelState(一个结构体收全部包级共享变量,字段
  nil 安全)、Module 接口、graphfile(依赖图编解码 + 与二进制 hash 对账)、
  scheduler(按 BFS 层并行 Init)、syncbus(层边界写通知)、
  lifecycle(Quiesce/Resume 可逆伪关机,区别于一次性 CloseAll)
- internal/kernelmod/<name>/:每个模块一个文件夹,Requires/Provides 自声明;
  all/all.go 是唯一的 import 清单(Go 循环导入约束)
- cmd/homed-depgraph:编译期生成依赖图,Makefile 注入 digest
- 运行期只**读**图,不计算顺序(顺序是编译期决定的,源码里没有中央清单)

## 判据

kernelboot 8 条 + 图失败模式 7 条(全部拒绝并给准确错误)+ 模块目录数
与注册表一致性。变异测试 15/15 咬住,含:
 · 删掉 AddCloser 的锁 → -race 报数据竞争
 · Scheduler 同批跑去重 → 203 vs 200
 · 图 hash 不匹配 → 拒绝启动

## 顺带修的缺陷

热更新路径漏调模块关闭 ⇒ 显式 stopKernelModules(),并用 AST 判据钉住
"必须在 os.Exit 之前"(字符串检查在本仓已骗过我三次)。

实测:冷启动 1630ms(5 层/17 模块,原 17 步串行);生产 22 插件全部建链。

* feat(security): 密钥防火墙 + 配置加密 + close 纪律

## 密钥防火墙

模型只见别名、不见明文;插件工具调用入口(StageHost.ExecuteTool)做入向
回填。三维能力:
 · 出向:工具输出里的密钥被替换成 [[secret:name]] 别名后才进上下文
 · 入向:模型传别名,入口处填回真值(插件完全无感知)
 · 存储:config.db 里不存明文(0644 的库可读),敏感值独立 0600 文件

模式库「宁漏勿误伤」:宁可漏掉一次替换,也不把正常文本误当密钥。

## 配置加密

- internal/security/{codec,key,keyring,store,global}.go:AES-256-GCM,
  enc:v1: 前缀,密钥轮换(OldKeys 解码 + 新钥加密)
- harden.go:数据目录权限收紧
- embedded.go:支持内嵌主密钥(可选,仍保留外部 key 文件路径)
- EncryptExistingSecrets / AuditSecrets:启动时幂等迁移 + 审计

## close 纪律(用户明确要求:只要调 close 就要有前置检查)

internal/safeclose:Safe / SafeFunc。全仓 37 处裸 close(chan) → 0。
关键教训(都写进注释和判据):
 · `select { case <-ch: default: close(ch) }` **不是**并发安全的
   (两个 goroutine 可同时命中 default)—— healthcheck 原先是这种写法
 · `sync.Once` **不可重入**:X.Do(func(){ Safe(&X, ...) }) 会自锁,
   症状是 go test 直接挂住(3 个包 600s 超时),栈里只有嵌套的 Once.Do
 · map 里按请求持有的 channel(mcp/stdio.go、clawhubadapter/sidecar.go)
   改成 *pendingRPC 包装,每通道一个 once

## 判据

security 全量 + TestNoBareCloseOutsideSafeclose(AST 级扫描,含注释剥离)
+ TestNoNestedSelfOnceDeadlock(正则修正:\w+ 不匹配点号,捕获组要含 '.')。
变异 2/2 咬住。全量 52 包 ok。

* feat(plugin+deploy): 插件三态生命周期、数据目录迁移、发行包修复

## 插件三态生命周期(用户要求:自动启动 / 启用但不自动启动 / 禁用)

- Manifest 新增 requires_facilities / auto_start(*bool,用于区分
  "未声明"与"显式 false")
- plugin-host 模块(internal/kernelmod/pluginhost):用 kernelboot.Scheduler
  按依赖时机拉起,**不再手写两阶段**
- 运行态停启 StopPlugin/StartPlugin **可逆**:不写 disabled_plugins
  (不改用户决定)、不置 shuttingDown(之后还能加载)、会摘掉注册面
- 与 StopAll 的区别写进注释 —— StopAll 置 shuttingDown 后**永久无法加载**,
  那是进程退出用的,误用于"暂停"就是关了就死

## 数据目录迁移(伪关机 → 迁移 → 唤醒)

- kernelboot/lifecycle.go:Quiesce/Resume(可逆,区别于一次性 CloseAll),
  逆序暂停、逆序恢复、Pause 失败回滚
- kernelboot/migrate.go:BeginMigration/End 编排(先停插件 → 再停内核写者
  → 等在途归零)
- distiller/textmem 增 Pause/Resume 门(不是 cancel —— cancel 不可逆,
  恢复要重建整个对象)
- 模块 migration:体检 + 生成可执行迁移脚本

★ 迁移脚本的三个实测教训(都写成了判据):
 1. 无条件改 /etc/systemd/system ⇒ 演练把**生产** -data 改成了演练目录。
    加 --dry-run(零改动)+ 强制校验 systemd 当前 -data == OLD
 2. 本机没装 rsync,脚本在"服务已停、数据未搬"处硬失败 ⇒ cp -a 回退,
    并明确警告不保硬链接/ACL(生产 plugindev 有大量 git 硬链接)

## 发行包修复(全部经实测确认)

 1. package-linux.sh **完全漏打 depgraph.bin** ⇒ 内核 exit 1
    ("生产构建(内嵌 hash 非空)但读不到依赖图")
 2. build_homed 不走 Makefile ⇒ 单独修 Makefile 无效,必须同时生成图+注入
 3. build.sh 的 LDFLAGS 用**历史模块路径 gitcode.com** ⇒ -X 静默 no-op
    ⇒ --version 显示 unknown
 4. Makefile -ldflags 用**单引号** ⇒ 注入了字面 $GRAPH_HASH

## ★ build.sh 的版本号必须校验(真实事故)

原 `VERSION="$(git describe --tags --dirty)"`。无 tag 分支上它返回**分支名**
`backup/pre-identity-rewrite-20261005-51-g03d87a4e-dirty`,被注入 meta.Version
⇒ 插件握手判据 MajorVersion() 把整串当主版本 ⇒ **22 个子进程插件全部
握手失败**("主版本不匹配")。内核活着、systemd active、日志每条都像
插件的问题 —— 实际是发行包带着非法版本号。

修法:git describe 只在形如 vX.Y.Z 时采用,否则回退 meta.Version(权威源),
并对最终值做形态守卫。deploy/packaging/version_guard_test.go 3 条判据,
变异 2/2 咬住。

## deb 的 systemd unit 改为 guard 模式

ExecStart=/usr/bin/homed --role=guard --data /var/lib/homeagent
(worker exit 42 → guard 重拉,不经过 systemd、不计失败)
含 RestartSteps/RestartMaxDelaySec/TimeoutStopSec + 硬化
(NoNewPrivileges/ProtectSystem/ProtectHome/DevicePolicy/ReadWritePaths)。

## 其他

- internal/plugin/proc:非法 JSON 帧的报错**可定位**(内容摘要 + 可能原因 +
  修法)。生产实测 1038 条非法帧(插件往 stdout 打日志 = 污染协议通道)
- 示例插件 4 个 14 处 fmt.Printf → log.Printf
- hmapdev 模板同步修复;scaffold 判据剥离注释(模板注释里就含 fmt.Printf)

* chore: 收尾(主密钥轮换命令、IPC/知识库加固、SDK 对齐)

- cmd/homed-key-rotate:把 security 里的 RotateKey/RotateKeyFile/RekeySecrets
  串成一条**可执行**的运维路径。没有它,E-1 就只是库里的能力而不是
  "出事时能做的事",而轮换的价值恰恰在于能执行。
- cmd/homed:kmod_runtime(模块装配入口)、plugins_scheduled(failback
  白名单)、memcandidate、bootstrap_order_test / shutdown_flush_test
- internal/ipc、internal/devicebridge、internal/knowledge:配套加固
- third_party/homeagent-sdk/sdk:PluginMgrAPI 增三态能力(mgrStop/mgrStart/
  mgrIsRuntimeStopped)+ settings 对齐

判据:bootstrap_order_test(启动顺序不依赖手写序列)、
shutdown_flush_test(停机的两条路径必须对称 —— exit 42 会跳过 defer,
热更新路径下模块关闭/flush 曾全部丢失)。

* test(security): 测试夹具改用不可误认的占位值(修 push protection 拦截)

GitHub secret scanning 拦住了推送:三处测试夹具用了"看起来像真密钥"的
字面量:

  internal/security/prefilter_test.go:39
  internal/security/store_test.go:177
  internal/security/survey_test.go:20

它们全是**假密钥**(`AKIAIOSFODNN7EXAMPLE` 是 AWS 官方文档的标准示例,
其余是 `abcdefghij...` 这类可读占位符),但扫描器无法区分 —— 它只看形态。

★ 不绕过(不用 unblock URL、不加 allowlist):直接改夹具。
原因:一旦允许"假密钥可以通过",真密钥混进来时就没有最后一道门了。

改法:保留**模式库匹配所需的结构**,把可识别内容换成 FAKE:
  glpat-abcdefghijklmnopqrst       → glpat-FAKE-TEST-TOKEN-NOT-REAL
  sk-abcdefghij...012345           → sk-FAKE-TEST-KEY-NOT-REAL-0000000
  AKIAIOSFODNN7EXAMPLE             → AKIA0000FAKE0000TEST   (保持 20 字符)
  ASIAIOSFODNN7EXAMPLE             → ASIA0000FAKE0000TEST
  xoxb-123456789012-abcdefghijklmnop → xoxb-FAKE-TEST-TOKEN-NOT-REAL

★ 中途踩到一次:AWS 键的模式是 `\b(AKIA|ASIA)[0-9A-Z]{16}\b`(共 20 字符),
我第一次替换成 19 字符的 `AKIAFAKETESTKEY0000` ⇒ 模式匹配不上 ⇒
TestSurvey_WhatIsCaught / TestRecognizesCommonShapes /
TestPrefilterNeverBlocksRealMatches 三条当场变红。
判据正确地拒绝了"为了过扫描器而削弱覆盖"。

顺带核实:全仓 grep 无任何真实密钥泄漏。

* fix(packaging): package-linux.sh 的版本解析与 build.sh 统一

上一版只修了 build.sh,漏了 package-linux.sh —— 它仍只看 git describe:

  VERSION="${VERSION:-$(git describe --tags --dirty)}"

无 tag 分支上 git describe 返回**分支名**
(backup/pre-identity-rewrite-20261005-56-g98850e1),守卫会正确拒绝,
但要求人**手动传 VERSION** 才能打包。而 build.sh 早已能自动回退 meta.Version
⇒ 同一个版本,走 build.sh 打得出来、走 package-linux.sh 打不出来。

危害不只是"要手动传参":运维为了跑通会去掉守卫,而分支名一旦进
meta.Version 就是**插件协议主版本**的来源 ⇒ 22 个子进程插件全部握手失败
(这是真实发生过的事故,见 3efa4e5)。

修法:两个脚本用同一套规则(git describe 仅在 vX.Y.Z 形态时采用,
否则回退 meta.Version),并对最终值做形态守卫。

判据:TestBothPackagingScriptsShareVersionRules —— 逐个读两个脚本,
要求都能从 meta/meta.go 取权威版本、都校验 git describe 形态。
变异(把 package-linux.sh 改回旧规则)咬住并准确指出是哪个脚本。

实测:不传 VERSION 跑 package-linux.sh,输出
  [PKG] warn: git describe 给出非版本串(...),改用 meta.Version=1.4.0
  Version: 1.4.0

* fix(test): 跨仓判据在 CI 上应 skip 而非 fail(修 PR 双红)

CI 上 "Go build / vet / test" 与 "Build linux/amd64" 双红:

  --- FAIL: TestHmapdevTemplateImplementsAllPluginMgrMethods
      bridge_sync_test.go:170: 读模板: open
      ../../../third_party/homeagent-sdk/tools/hmapdev/templates/proc_main.go.tmpl:
      no such file or directory

## 根因:判据依赖了一个**被 gitignore 的路径**

`.gitignore:33` 有 `third_party/homeagent-sdk/tools/`(SDK 仓自治,本仓不跟踪)。
于是:
  · 本机(完整 SDK 检出)有该文件 ⇒ 判据通过
  · CI(只 checkout 本仓)没有该文件 ⇒ Fatalf ⇒ **必红**

我提交时只在本机跑测试、没看 CI,所以这个问题拖到 PR 才暴露。

## 修法

文件缺失时 `t.Skipf` 而不是 `t.Fatalf`。

★ 这不是"放弃检查":
  · 跨仓一致性由 **SDK 仓自己的判据**守着
    (tools/hmapdev/scaffold_stdout_test.go 等),那里才是模板的真实归属地
  · 本判据在本机开发时**仍然生效**(已验证:有文件时 PASS,缺文件时 SKIP)

## 验证

 · 本机(有文件)  → PASS(判据真跑)
 · 模拟 CI(移走 tools/)→ SKIP(不再 fail)
 · 全量 53 包 ok / 0 fail

## 顺带

同目录还有 e2e_template_test.go 也读 tools/,但它是 `//go:build` 门控的
e2e 判据,CI 默认不跑,无需处理。

---------

Co-authored-by: JianFeeeee <JianFeeeee@users.noreply.gitcode.com>
This commit is contained in:
JianFeeeee
2026-10-17 19:18:24 +08:00
committed by GitHub
parent 03d87a4e21
commit a68d6a428c
266 changed files with 30185 additions and 1424 deletions

View File

@ -0,0 +1,77 @@
package kernelboot
// 把既有的内核 `init*` 函数**适配**成 Module,而不必重写它们的签名。
//
// ## 为什么不直接改 init* 的签名
//
// `init*` 函数是**既有代码**,各有不同签名(有的返回 1 个值、有的 2 个、
// 有的带 close 函数、有的只做副作用)。逐个改签名意味着:
// · 大面积改动(评审困难、易引入回归)
// · 且 `init*` 函数本身是清晰的(`initLuaVM(cfg) (*VM, func())`)——
// 为迁就调度器的接口而把它改丑,是**倒置的取舍**
//
// 所以本文件提供**适配层**:声明式地描述"这个模块的名字/依赖/产物/怎么调用",
// `init*` 函数一行都不改。
//
// ## 设计:FuncModule
//
// ```go
// kernelboot.FuncModule(kernelboot.FuncSpec{
// Name: "lua",
// Requires: []string{"config"},
// Provides: []string{"lua"},
// Init: func(ks *kernelboot.KernelState) error {
// vm, closeFn := initLuaVM(ks.Cfg) // 依赖的产物:直接读字段
// ks.LuaVM = vm // 自己的产物:写回字段
// ks.AddCloser(closeFn) // 关闭函数统一登记
// return nil
// },
// })
// ```
//
// ## 为什么"关闭函数"也要作为产物发布
//
// `init*` 常返回 `(value, func())`:value 要用,close 要在停机时调。
// 若适配器只发布 value,**close 就丢了** ⇒ 资源泄漏(实测有先例:
// tracker 的 overlay 挂载、memory stack 的关闭)。
//
// 把 close 也当产物(约定名 `<facility>.close`)的好处:
// 停机时统一遍历容器调用所有 close,**不依赖人记得在 main 末尾手写**。
// 这正好顺手解决了 main() 里那一堆 `defer closeMem()` 的散落问题。
// FuncSpec 描述一个由函数适配的模块。
type FuncSpec struct {
Name string
Requires []string
Provides []string
// Init 执行装配:从 ctx 取依赖、调既有函数、把产物 Put 回 ctx。
Init func(ks *KernelState) error
}
// funcModule 是 FuncSpec 的 Module 实现。
type funcModule struct {
spec FuncSpec
}
func (m *funcModule) Name() string { return m.spec.Name }
func (m *funcModule) Provides() []string { return m.spec.Provides }
func (m *funcModule) Requires() []string { return m.spec.Requires }
func (m *funcModule) Init(ks *KernelState) error {
if m.spec.Init == nil {
return nil
}
return m.spec.Init(ks)
}
// FuncModule 创建一个由函数适配的模块。
func FuncModule(spec FuncSpec) Module {
return &funcModule{spec: spec}
}
// --- 关闭函数的统一回收 ---
//
// 约定:模块在 Init 里用 ks.AddCloser(fn) 注册关闭函数。
// 停机时由 KernelState.CloseAll 按**注册顺序的逆序**执行(LIFO,与 defer 一致)。
//
// ★ 为何不再用"设施名以 .close 结尾"的约定:那个约定要靠字符串判断,
// 而结构体方案下直接 AddCloser 更直白(且不会因为设施改名而失效)。

View File

@ -0,0 +1,443 @@
package kernelboot
// 依赖图的**制品化**:编译期产出的精密二进制 + 双重 hash 保护。
//
// 设计权威:docs/zh/kernel-modularization-design.md §2.5。
//
// ## 两个不同的 hash,各防一类故障(★ 关键区分)
//
// ① 图内自校验 hash 防「图文件损坏」(磁盘错、传输截断、位翻转)
// ② 内嵌 hash(二进制) 防「图与二进制不匹配」(改了 Requires 但图没重生成)
//
// ★ 只做 ① 会漏掉更危险的那类:图文件**自身完好**、自校验也过,
// 但它描述的是**旧代码** ⇒ 按旧图调度 ⇒ 顺序错 ⇒ 正是本设计要消灭的病 A。
//
// 所以 §2.5.2 要求 hash 绑定二进制,且不一致时**拒绝启动**(不猜哪边对)。
import (
"bytes"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"fmt"
"os"
"sort"
"strings"
)
// 文件格式常量。
const (
// graphMagic 是格式标识。破坏性变更时改末位数字。
graphMagic = "HGDEPS01"
// graphFormatVersion 是格式版本(与 magic 末位同步维护)。
graphFormatVersion = 2
// GraphFileName 是制品文件名(与内核二进制同目录,满足"随核心分发")。
GraphFileName = "depgraph.bin"
// maxFieldLen 是单个字符串字段的长度上限(防损坏文件声明一个超大长度)。
//
// 为何必须设限:一个损坏的长度字段会让 make([]byte, n) 申请巨量内存
// (或直接 OOM)。解析器对**不可信输入**必须设边界。
maxFieldLen = 4096
// maxModules 是模块数上限(同上,防损坏文件声明一个巨大计数)。
maxModules = 4096
)
// GraphEdge 是图中一条模块声明(用于序列化)。
type GraphEdge struct {
Name string
Provides []string
Requires []string
// Layer 是**编译期算好的起始层号**(设计 §2.5,用户要求:
// "启动顺序是编译期间按照声明的依赖顺序生成的启动依赖图排列的")。
//
// ## 为什么把层号也编码进图(而不只存边)
//
// 若只存边,运行期就还得跑一次 BFS 才算得出顺序 —— 那等于
// **内核在运行期决定启动顺序**,与"编译期定序"的设计相悖
// (且运行期算法与编译期不一致时无人察觉)。
//
// 存下 Layer 后:运行期**读**它即可,不重算。
// `Build()` 仍保留(单测/无图场景用),但**生产路径不调它**。
//
// ★ 自校验:读图时仍会重算分层并与 Layer 比对(CheckLayers),
// 不一致 ⇒ 拒绝启动。这样"图里的顺序"与"声明"不会漂移。
Layer int
}
// GraphFile 是图制品的解析结果。
type GraphFile struct {
Version int
Modules []GraphEdge
// Digest 是文件内自校验 hash 的十六进制(§2.5.2 的 ①)。
Digest string
}
// buildGraphPayload 产出**参与 hash 与序列化**的全部字节(不含自校验 hash 本身)。
//
// ★ 所有字段**排序**:同一份声明必须产出逐字节相同的结果。
// 否则代码里调换两个模块的顺序就会让 hash 变化 ⇒
// "每次构建都说不一致" ⇒ hash 失去意义(人开始无视它)。
func buildGraphPayload(modules []GraphEdge) []byte {
// 深拷贝并排序(不改调用方的切片)
ms := make([]GraphEdge, len(modules))
copy(ms, modules)
for i := range ms {
ms[i].Provides = sortedCopy(ms[i].Provides)
ms[i].Requires = sortedCopy(ms[i].Requires)
}
// ★ 排序键是 **(Layer, Name)** —— 不是只按名字。
//
// 这样**解码后的顺序就是启动顺序**(先按层,层内按名字),
// 运行期读到即可直接执行,不必重算分层。
//
// 层内按名字是为了**可复现**(同一份声明两次产出逐字节相同 ⇒ 能进 PR diff)。
sort.Slice(ms, func(i, j int) bool {
if ms[i].Layer != ms[j].Layer {
return ms[i].Layer < ms[j].Layer
}
return ms[i].Name < ms[j].Name
})
var buf bytes.Buffer
buf.WriteString(graphMagic)
_ = binary.Write(&buf, binary.LittleEndian, uint32(graphFormatVersion))
_ = binary.Write(&buf, binary.LittleEndian, uint32(len(ms)))
writeStr := func(s string) {
_ = binary.Write(&buf, binary.LittleEndian, uint16(len(s)))
buf.WriteString(s)
}
for _, m := range ms {
writeStr(m.Name)
writeStr(strings.Join(m.Provides, ","))
writeStr(strings.Join(m.Requires, ","))
// Layer 作为 uint32 追加在每条记录末尾。
//
// ★ 放在末尾而不是开头:这样"旧版解码逻辑"遇到新文件时
// 会在**尾部残留检查**处报错(响亮),而不是把 layer 当名字读错。
_ = binary.Write(&buf, binary.LittleEndian, uint32(m.Layer))
}
return buf.Bytes()
}
func sortedCopy(in []string) []string {
out := append([]string(nil), in...)
sort.Strings(out)
return out
}
// EncodeGraph 把模块声明序列化成制品字节(含自校验 hash)。
func EncodeGraph(modules []GraphEdge) []byte {
payload := buildGraphPayload(modules)
sum := sha256.Sum256(payload)
out := make([]byte, 0, len(payload)+sha256.Size)
out = append(out, payload...)
out = append(out, sum[:]...)
return out
}
// GraphDigest 返回制品自校验 hash 的十六进制(不含文件本身)。
//
// 供构建期把 digest 注入二进制(§2.5.2 的 ②)。
func GraphDigest(modules []GraphEdge) string {
sum := sha256.Sum256(buildGraphPayload(modules))
return hex.EncodeToString(sum[:])
}
// DecodeGraph 解析制品字节,校验自校验 hash。
//
// 任何结构异常都**报错**(不返回"部分结果"):
// 图是启动调度的唯一依据,残缺的图比没有图更危险
// (它会生成一个**看似合法**的调度计划)。
func DecodeGraph(data []byte) (*GraphFile, error) {
if len(data) < len(graphMagic)+8+sha256.Size {
return nil, fmt.Errorf("kernelboot: 依赖图文件过短(%d 字节),已损坏", len(data))
}
// ① 先校验自校验 hash(防截断/位翻转)
payload := data[:len(data)-sha256.Size]
wantSum := data[len(data)-sha256.Size:]
gotSum := sha256.Sum256(payload)
if !bytes.Equal(gotSum[:], wantSum) {
return nil, fmt.Errorf("kernelboot: 依赖图自校验失败(文件已损坏)")
}
// ② 解析
if string(payload[:len(graphMagic)]) != graphMagic {
return nil, fmt.Errorf("kernelboot: 依赖图格式标识不匹配(期望 %q,得到 %q)——"+
"文件可能来自不兼容的版本", graphMagic, string(payload[:len(graphMagic)]))
}
off := len(graphMagic)
rd32 := func() uint32 {
v := binary.LittleEndian.Uint32(payload[off:])
off += 4
return v
}
ver := rd32()
if ver != graphFormatVersion {
return nil, fmt.Errorf("kernelboot: 依赖图格式版本 %d 与本二进制期望的 %d 不一致——"+
"请用配套的生成器重出图", ver, graphFormatVersion)
}
n := rd32()
if n > maxModules {
return nil, fmt.Errorf("kernelboot: 依赖图声明 %d 个模块,超过上限 %d(文件已损坏)",
n, maxModules)
}
readStr := func() (string, error) {
if off+2 > len(payload) {
return "", fmt.Errorf("kernelboot: 依赖图在读取字符串长度时越界")
}
l := int(binary.LittleEndian.Uint16(payload[off:]))
off += 2
if l > maxFieldLen {
return "", fmt.Errorf("kernelboot: 依赖图字段长度 %d 超过上限 %d(文件已损坏)",
l, maxFieldLen)
}
if off+l > len(payload) {
return "", fmt.Errorf("kernelboot: 依赖图字段越界(需要 %d 字节,剩余 %d)",
l, len(payload)-off)
}
s := string(payload[off : off+l])
off += l
return s, nil
}
gf := &GraphFile{Version: int(ver), Digest: hex.EncodeToString(gotSum[:])}
for i := uint32(0); i < n; i++ {
name, err := readStr()
if err != nil {
return nil, err
}
provs, err := readStr()
if err != nil {
return nil, err
}
reqs, err := readStr()
if err != nil {
return nil, err
}
if off+4 > len(payload) {
return nil, fmt.Errorf("kernelboot: 依赖图在读取层号时越界(文件已损坏)")
}
layer := int(binary.LittleEndian.Uint32(payload[off:]))
off += 4
gf.Modules = append(gf.Modules, GraphEdge{
Name: name,
Provides: splitNonEmpty(provs),
Requires: splitNonEmpty(reqs),
Layer: layer,
})
}
if off != len(payload) {
return nil, fmt.Errorf("kernelboot: 依赖图有 %d 字节尾部残留(文件格式不符)",
len(payload)-off)
}
return gf, nil
}
func splitNonEmpty(s string) []string {
if s == "" {
return nil
}
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p != "" {
out = append(out, p)
}
}
return out
}
// CheckAgainstBinary 校验磁盘上的图与**本二进制**是否配套(§2.5.2)。
//
// embeddedHash 是构建期注入的 digest(空 = 开发环境,跳过校验)。
//
// ★ 不一致时**拒绝启动**,不猜哪边对:
// 图的作者是构建流程,二进制的作者也是它。两者不一致意味着制品装配出了问题,
// 此时任何一边都不可信,猜一个是拿启动正确性赌运气。
func CheckAgainstBinary(gf *GraphFile, embeddedHash string) error {
if embeddedHash == "" {
// 开发/单测环境:构建脚本没注入 digest ⇒ 不做配套校验。
// 这让 `go test` / `go run` 无需先生成图。
return nil
}
if gf == nil {
return fmt.Errorf("kernelboot: 缺少依赖图,而本二进制要求必须有(内嵌 hash=%s)——"+
"生产环境不允许回退到运行期重建", embeddedHash[:min(8, len(embeddedHash))])
}
if !strings.EqualFold(gf.Digest, embeddedHash) {
return fmt.Errorf("kernelboot: ★ 依赖图与本二进制**不配套**\n"+
" 磁盘图的 hash: %s\n"+
" 二进制内嵌的: %s\n"+
"最常见原因:改了模块的 Requires/Provides 但**没有重新生成依赖图**。\n"+
"修复:重新运行构建(会重出图并注入新 hash),不要手工改图文件。",
gf.Digest, embeddedHash)
}
return nil
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
// EmbeddedGraphHash 由**构建期**用 -ldflags 注入(设计 §2.5.2)。
//
// -X github.com/JianFeeeee/HomeAgent/internal/kernelboot.EmbeddedGraphHash=<digest>
//
// 空值(默认)= 开发/单测环境:不做"图与二进制配套"校验,
// 允许 homed 回退到运行期从声明重建图。
//
// ★ 为何默认空而不是某个占位值:`go test` / `go run` 不可能先跑生成器。
// 若默认非空,所有本地开发与判据都会因"图不存在"而失败 ——
// 那会逼着人绕过检查(比没有检查更糟)。
var EmbeddedGraphHash string
// PlanFromGraph 从**编译期产出的图**构造启动计划(设计 §2.5,用户要求:
// "启动顺序是编译期间按照声明的依赖顺序生成的启动依赖图排列的")。
//
// ## 与 Build() 的区别(这是本函数存在的理由)
//
// Build(mods) ⇒ 运行期**现算**分层(依赖 BFS 算法)
// PlanFromGraph(gf, …) ⇒ **读**编译期算好的层号,不重算
//
// 按用户设计,生产启动路径走后者:**顺序在编译期定,运行期只执行**。
//
// ## ★ 自校验:仍会重算一次并比对(防"图与代码漂移")
//
// 只读不验会有个危险:图里写的是旧顺序(改了 Requires 但没重出图),
// 而文件自身 hash 完全有效 ⇒ 按**旧顺序**启动(正是 §2.5.2 的病 A)。
//
// 所以本函数:
//
// ① 从图里取模块与层号
// ② 用**图里的边**重算一次分层(Build)
// ③ 比对:重算结果与图中层号不一致 ⇒ **拒绝启动**
//
// 第 ③ 步能同时抓两类问题:图内自相矛盾(边与层号不符)、
// 生成器算法与运行期算法不一致。**两者都是制品装配错误,不该猜哪边对。**
//
// ## 参数 mods
//
// 是**当前二进制里真实登记的模块**(来自 kernelmod 注册表)。
// 它有两个作用:
//
// · 校验"图里描述的模块"与"本二进制实际有的模块"一致
// (防:图是新的但二进制是旧的,或反之)
// · 提供实现体(图只有名字,没有 Init)
func PlanFromGraph(gf *GraphFile, mods []Module) (*Plan, error) {
if gf == nil {
return nil, fmt.Errorf("kernelboot: 依赖图为 nil")
}
// ① 建立 名字 → 实现 的映射
byName := make(map[string]Module, len(mods))
for _, m := range mods {
if _, dup := byName[m.Name()]; dup {
return nil, fmt.Errorf("kernelboot: 模块名重复: %s", m.Name())
}
byName[m.Name()] = m
}
// ② 图与二进制必须描述**同一组模块**
var missingInBinary, missingInGraph []string
for _, e := range gf.Modules {
if _, ok := byName[e.Name]; !ok {
missingInBinary = append(missingInBinary, e.Name)
}
}
inGraph := make(map[string]bool, len(gf.Modules))
for _, e := range gf.Modules {
inGraph[e.Name] = true
}
for _, m := range mods {
if !inGraph[m.Name()] {
missingInGraph = append(missingInGraph, m.Name())
}
}
if len(missingInBinary) > 0 || len(missingInGraph) > 0 {
sort.Strings(missingInBinary)
sort.Strings(missingInGraph)
return nil, fmt.Errorf("kernelboot: 依赖图与二进制不一致 —— "+
"图里有但二进制没有: %v;二进制有但图里没有: %v。"+
"这通常意味着**图与二进制不是同一次构建产出的**(请重新构建)",
missingInBinary, missingInGraph)
}
// ③ 自校验:用图里的边重算分层,与图中层号比对
edges := make([]GraphEdge, len(gf.Modules))
copy(edges, gf.Modules)
recomputed, err := Build(mods)
if err != nil {
return nil, fmt.Errorf("kernelboot: 无法用当前模块重算分层(声明之间有冲突): %w", err)
}
layerOf := map[string]int{}
for i, l := range recomputed.Layers {
for _, m := range l {
layerOf[m.Name()] = i
}
}
var drift []string
for _, e := range gf.Modules {
if got, ok := layerOf[e.Name]; ok && got != e.Layer {
drift = append(drift, fmt.Sprintf("%s(图中第 %d 层,按声明算应为第 %d 层)",
e.Name, e.Layer, got))
}
}
if len(drift) > 0 {
sort.Strings(drift)
return nil, fmt.Errorf("kernelboot: ★ 依赖图与代码声明**漂移** —— %s。"+
"说明改了 Requires/Provides 但**没有重新生成图**。"+
"按此图启动会得到错误的顺序(§2.5.2 的病 A),故拒绝启动。"+
"修复:重新执行构建(make build 会重出图并注入 hash)",
strings.Join(drift, ";"))
}
// ④ 按图中的 (Layer, Name) 组装计划 —— **直接读**,不再排序/重算
sorted := make([]GraphEdge, len(gf.Modules))
copy(sorted, gf.Modules)
sort.Slice(sorted, func(i, j int) bool {
if sorted[i].Layer != sorted[j].Layer {
return sorted[i].Layer < sorted[j].Layer
}
return sorted[i].Name < sorted[j].Name
})
plan := &Plan{}
for _, e := range sorted {
for len(plan.Layers) <= e.Layer {
plan.Layers = append(plan.Layers, nil)
}
plan.Layers[e.Layer] = append(plan.Layers[e.Layer], byName[e.Name])
}
return plan, nil
}
// LoadAndCheckGraph 读图并做"图与二进制配套"校验(§2.5.2)。
//
// path 为空时用默认位置(与二进制同目录,见 GraphFileName)。
// embeddedHash 来自 -ldflags 注入(EmbeddedGraphHash)。
//
// 缺失策略(§2.5.4):dev(embeddedHash=="")允许没有图 ⇒ 返回 (nil, nil),
// 由调用方回退到运行期 Build;生产(有 hash)必须有图,否则报错。
func LoadAndCheckGraph(path, embeddedHash string) (*GraphFile, error) {
b, err := os.ReadFile(path)
if err != nil {
if embeddedHash == "" {
return nil, nil // dev:没有图是正常的
}
return nil, fmt.Errorf("kernelboot: 生产构建(内嵌 hash 非空)但读不到依赖图 %s: %w —— "+
"图必须与二进制同目录(部署时一起拷贝)", path, err)
}
gf, err := DecodeGraph(b)
if err != nil {
return nil, err
}
if err := CheckAgainstBinary(gf, embeddedHash); err != nil {
return nil, err
}
return gf, nil
}

View File

@ -0,0 +1,291 @@
package kernelboot
// 依赖图制品化的判据(2026-10-16)。
//
// 设计见 docs/zh/kernel-modularization-design.md §2.5。
//
// 这组判据的重心是**两个不同 hash 各防一类故障**(§2.5.2):
//
// ① 图内自校验 ⇒ 防「图文件损坏」
// ② 内嵌 hash ⇒ 防「图与二进制不匹配」
//
// ★ 只测 ① 是不够的:图文件自身完好、自校验也过,但它描述的是**旧代码**
// ⇒ 按旧图调度 ⇒ 顺序错 ⇒ 正是本设计要消灭的病 A。
// 所以 ② 必须有独立判据,且要证明它**真的会拦住不匹配**。
import (
"crypto/sha256"
"os"
"path/filepath"
"strings"
"testing"
)
func sampleEdges() []GraphEdge {
return []GraphEdge{
{Name: "security", Provides: []string{"crypto", "keystore"}},
{Name: "config", Provides: []string{"config"}, Requires: []string{"crypto"}},
{Name: "llm", Provides: []string{"llm"}, Requires: []string{"config", "keystore"}},
}
}
// ★★★ 判据:编解码**往返一致**(含嵌套依赖的形状)。
func TestGraphEncodeDecodeRoundTrip(t *testing.T) {
in := sampleEdges()
data := EncodeGraph(in)
gf, err := DecodeGraph(data)
if err != nil {
t.Fatalf("解码失败: %v", err)
}
if len(gf.Modules) != len(in) {
t.Fatalf("模块数不符: %d vs %d", len(gf.Modules), len(in))
}
// 按名字比对(编码时排序了,所以顺序可能不同 —— 这是刻意的)
byName := map[string]GraphEdge{}
for _, m := range gf.Modules {
byName[m.Name] = m
}
for _, want := range in {
got, ok := byName[want.Name]
if !ok {
t.Fatalf("模块 %s 丢失", want.Name)
}
if strings.Join(got.Provides, ",") != strings.Join(sortedCopy(want.Provides), ",") {
t.Errorf("%s 的 Provides 不符: %v vs %v", want.Name, got.Provides, want.Provides)
}
if strings.Join(got.Requires, ",") != strings.Join(sortedCopy(want.Requires), ",") {
t.Errorf("%s 的 Requires 不符: %v vs %v", want.Name, got.Requires, want.Requires)
}
}
}
// ★★★ 判据:**同一份声明产出逐字节相同的结果**(hash 稳定的前提)。
//
// 为何必须独立判据:如果顺序影响字节,hash 在无害改动(代码里调换两个模块)
// 后也会变 ⇒ "每次构建都说不一致" ⇒ 人开始无视告警 ⇒ hash 失去意义。
func TestGraphEncodingIsOrderIndependent(t *testing.T) {
a := sampleEdges()
// 打乱顺序(含 Provides/Requires 数组内部顺序)
b := []GraphEdge{
{Name: "llm", Provides: []string{"llm"}, Requires: []string{"keystore", "config"}},
{Name: "config", Provides: []string{"config"}, Requires: []string{"crypto"}},
{Name: "security", Provides: []string{"keystore", "crypto"}},
}
da, db := EncodeGraph(a), EncodeGraph(b)
if !bytesEqual(da, db) {
t.Fatalf("★ 声明顺序不同导致字节不同(hash 会漂移,告警失去意义)\n"+
"a=%x\nb=%x", da[:32], db[:32])
}
if GraphDigest(a) != GraphDigest(b) {
t.Fatalf("★ digest 随声明顺序变化: %s vs %s", GraphDigest(a), GraphDigest(b))
}
}
// ★★★ **关键判据 ①**:图文件损坏必须被拦。
func TestGraphDetectsFileCorruption(t *testing.T) {
data := EncodeGraph(sampleEdges())
// ① 位翻转(模拟磁盘错误)
flipped := append([]byte(nil), data...)
flipped[len(flipped)/2] ^= 0x01
if _, err := DecodeGraph(flipped); err == nil {
t.Fatal("★ 位翻转未被检出(自校验失效)")
}
// ② 截断(模拟传输不完整)
if _, err := DecodeGraph(data[:len(data)-4]); err == nil {
t.Fatal("★ 截断未被检出")
}
// ③ 尾部多余字节
if _, err := DecodeGraph(append(append([]byte(nil), data...), 0xAA)); err == nil {
t.Fatal("★ 尾部残留未被检出")
}
// ④ 空文件 / 过短
for _, n := range []int{0, 8, 16} {
if _, err := DecodeGraph(data[:n]); err == nil {
t.Errorf("★ 长度为 %d 的输入未被拒", n)
}
}
}
// ★★★ **关键判据 ②**:格式标识/版本不符必须被拦(防来自不兼容版本的图)。
func TestGraphRejectsWrongMagicAndVersion(t *testing.T) {
data := EncodeGraph(sampleEdges())
// 改 magic 后重算自校验(模拟"图是完好的,但来自别的格式版本")
bad := append([]byte(nil), data...)
copy(bad[0:8], []byte("HGDEPS99"))
// 重算末尾 hash 让它"自校验通过",从而**只有版本检查能拦住它**
fixSelfHash(t, bad)
if _, err := DecodeGraph(bad); err == nil {
t.Fatal("★ 格式标识不符未被检出")
} else if !strings.Contains(err.Error(), "格式标识") && !strings.Contains(err.Error(), "版本") {
t.Errorf("错误应点明格式/版本问题: %v", err)
}
}
// ★★★ 判据:损坏的长度字段不得导致巨额分配(解析器对不可信输入要有界)。
func TestGraphRejectsAbsurdLengths(t *testing.T) {
// 手工构造:合法头部 + 一个超大长度
//
// ★ 必须构造成"自校验能通过"的样子,才能证明**长度检查**本身在起作用
// (否则是自校验先拦下,判据测不到长度检查)。
var b []byte
b = append(b, []byte(graphMagic)...)
b = append(b, 1, 0, 0, 0) // version = 1
b = append(b, 1, 0, 0, 0) // modCount = 1
b = append(b, 0xFF, 0xFF) // nameLen = 65535(超过 maxFieldLen=4096)
b = append(b, make([]byte, 32)...) // 自校验 hash 占位(下面重算)
fixSelfHash(t, b)
if _, err := DecodeGraph(b); err == nil {
t.Fatal("★ 超大字段长度未被拒(可能造成巨额分配)")
}
}
// ★★★ **最重要的判据**:图与二进制**不配套**必须被拦(§2.5.2 的情况 2)。
//
// 这是本设计的核心保护:图文件自身完好、自校验也过(所以 ① 拦不住),
// 但它描述的是**旧代码** ⇒ 按旧图调度 ⇒ 顺序错 = 病 A。
func TestGraphMismatchWithBinaryIsRejected(t *testing.T) {
// 实际产出的图(当前代码)
actual := EncodeGraph(sampleEdges())
gf, err := DecodeGraph(actual)
if err != nil {
t.Fatal(err)
}
// 二进制内嵌的是**另一份声明**的 hash(模拟"改了代码但没重出图")
oldCode := []GraphEdge{
{Name: "security", Provides: []string{"crypto"}},
{Name: "config", Provides: []string{"config"}, Requires: []string{"crypto"}},
// 注意:llm 模块当时还不存在
}
staleHash := GraphDigest(oldCode)
err = CheckAgainstBinary(gf, staleHash)
if err == nil {
t.Fatal("★ 图与二进制不配套却通过了校验 —— 会按旧图调度(病 A 复发)")
}
// 错误必须给出**可执行的修复方向**
for _, want := range []string{"不配套", "重新"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("错误应含 %q 以指导修复: %v", want, err)
}
}
// 且必须同时打印两个 hash(便于人工核对是哪边旧了)
if !strings.Contains(err.Error(), gf.Digest[:8]) || !strings.Contains(err.Error(), staleHash[:8]) {
t.Errorf("错误应同时给出两个 hash 的前缀: %v", err)
}
// 配套时必须通过
if err := CheckAgainstBinary(gf, gf.Digest); err != nil {
t.Errorf("配套的图不该被拒: %v", err)
}
}
// ★★★ 判据:**开发环境**(未注入 hash)不做配套校验,但**生产**(已注入)缺图即错。
//
// 为何要区分:单测/`go run` 不可能先跑生成器;
// 而生产缺图若静默回退到"运行期重建",就把 §2.5.2 要防的风险变成静默容忍。
func TestGraphPresencePolicyByEnvironment(t *testing.T) {
gf, _ := DecodeGraph(EncodeGraph(sampleEdges()))
// ① 开发:未注入 hash ⇒ 跳过校验(无论图在不在)
if err := CheckAgainstBinary(gf, ""); err != nil {
t.Errorf("开发环境(未注入 hash)不该做配套校验: %v", err)
}
if err := CheckAgainstBinary(nil, ""); err != nil {
t.Errorf("开发环境缺图不该报错: %v", err)
}
// ② 生产:已注入 hash ⇒ 缺图必须报错(不许回退到重建)
err := CheckAgainstBinary(nil, "deadbeefdeadbeef")
if err == nil {
t.Fatal("★ 生产环境(已注入 hash)缺图却通过了 —— 会静默回退到运行期重建")
}
if !strings.Contains(err.Error(), "生产") && !strings.Contains(err.Error(), "回退") {
t.Errorf("错误应点明不允许回退: %v", err)
}
}
// ★★★ 判据:**落盘后读回**(真实文件 IO 路径,不只是内存往返)。
func TestGraphFileRoundTripOnDisk(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, GraphFileName)
want := EncodeGraph(sampleEdges())
if err := os.WriteFile(path, want, 0o644); err != nil {
t.Fatal(err)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
gf, err := DecodeGraph(got)
if err != nil {
t.Fatalf("从磁盘读回后解码失败: %v", err)
}
if len(gf.Modules) != 3 {
t.Errorf("模块数不符: %d", len(gf.Modules))
}
// 文件命名必须与文档一致(生产靠这个名字找图)
if GraphFileName != "depgraph.bin" {
t.Errorf("制品文件名应为 depgraph.bin(文档 §2.5.4),实际 %s", GraphFileName)
}
}
// ★★★ 判据:嵌套依赖的形状在制品里**保真**(用户的明确要求)。
//
// 场景:llm 依赖 config,config 依赖 crypto(crypto 由 security 提供)
// ⇒ 制品必须完整保留这条链,而不是被"展平"或丢失中间层。
func TestGraphPreservesNestedDependencyChain(t *testing.T) {
edges := []GraphEdge{
{Name: "security", Provides: []string{"crypto"}},
{Name: "config", Provides: []string{"config"}, Requires: []string{"crypto"}},
{Name: "llm", Provides: []string{"llm"}, Requires: []string{"config"}},
{Name: "agent", Provides: []string{"agent"}, Requires: []string{"llm"}},
}
gf, err := DecodeGraph(EncodeGraph(edges))
if err != nil {
t.Fatal(err)
}
byName := map[string]GraphEdge{}
for _, m := range gf.Modules {
byName[m.Name] = m
}
// 逐环核对链条完整性
if got := byName["config"].Requires; len(got) != 1 || got[0] != "crypto" {
t.Errorf("config 的依赖链断裂: %v", got)
}
if got := byName["llm"].Requires; len(got) != 1 || got[0] != "config" {
t.Errorf("llm 的依赖链断裂: %v", got)
}
if got := byName["agent"].Requires; len(got) != 1 || got[0] != "llm" {
t.Errorf("agent 的依赖链断裂: %v", got)
}
}
// fixSelfHash 重算末尾的 32 字节自校验 hash,让"非法内容但自校验通过"成为可能
// (用于测试**格式检查**而不是 hash 检查)。
//
// 前提:data 长度 >= 32(即已有 32 字节 hash 尾巴)。用例需自行保证。
func fixSelfHash(t *testing.T, data []byte) {
t.Helper()
if len(data) < 32 {
t.Fatalf("判据构造错误:payload 太短(%d 字节),无法容纳 32 字节自校验 hash", len(data))
}
payload := data[:len(data)-32]
sum := sha256.Sum256(payload)
copy(data[len(data)-32:], sum[:])
}
func bytesEqual(a, b []byte) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}

View File

@ -0,0 +1,402 @@
// Package kernelboot 是内核模块的**声明式装配调度器**。
//
// 设计权威:docs/zh/kernel-modularization-design.md §2.3。
//
// ## 要解决的问题(实测,2026-10-16)
//
// `cmd/homed/main.go` 里 277 行装配代码、11 处「必须早于 / 必须在之前」注释。
// 这些**顺序依赖只写在注释里,没有任何机器校验** —— 错了不报错,
// 而是静默降级,症状出现在很远的地方。
//
// 现场故障:安全模块装配晚于读 `core.llm.*` 的操作 ⇒ 密文解不开 ⇒
// key 变空 ⇒ 所有 LLM 调用 401,而看起来像"key 配错了"。
//
// ## 本包提供的性质(用户指出的核心洞察)
//
// > 依赖图是一个由依赖关系生成的图,通过 BFS 遍历:当前遍历到的节点初始化后,
// > 下一层 BFS 就可以安全地并行初始化 —— 这是依赖图的性质。
//
// 形式化:设边 `a → b` 表示 b 依赖 a。BFS 从入度 0 的节点开始分层。
// 第 k 层任意节点的**全部前驱都在 <k 层**,且**第 k 层内部两两无边**
// (否则其中一个会在第 ≥k+1 层)。
//
// ⇒ 只要第 <k 层已全部就绪,第 k 层的**任意子集**都可以并发 Init。
//
// 分层本身已经保证了并行安全,无需任何额外判断。
//
// 这就是为什么用 BFS 而不是「一次拓扑排序 + 逐个执行」:
// 拓扑序给的是**一个**合法顺序(全串行),BFS 分层给的是**最大化并行的顺序集合**。
//
// ## 本包只做纯逻辑
//
// 不依赖任何内核子系统,因此可以用极硬的判据覆盖:
// 分层正确性、同层并行、有界并发、三类失败(缺依赖/循环/Init 错)。
// 真正的模块定义与接线在 Step 4 的后续增量里做(见文档 §6)。
package kernelboot
import (
"fmt"
"sort"
"strings"
"sync"
)
// Module 是一个可装配的内核模块。
//
// ★ 注意 Requires 的语义:**不是"我要在谁之后",而是"我需要谁已就绪"**。
// 前者是顺序(要人肉维护),后者是依赖(可由图推导)——
// 本设计的全部价值就在于把前者换成后者。
type Module interface {
// Name 是模块的唯一标识(依赖图节点名)。
Name() string
// Requires 声明依赖的**设施名**(即其他模块 Provides 的名字)。
Requires() []string
// Provides 声明本模块提供的设施名(供他人 Requires 引用)。
//
// 允许一个模块提供多个设施名(如"配置库"同时提供"配置读"与"配置写")。
// 返回空表示"不提供任何可被依赖的设施"(纯副作用模块)。
Provides() []string
// Init 执行装配。ks 是内核**唯一的公共数据结构体**:
// 模块从中**读**已就绪的依赖,把自己产出的公共数据**写**回它的字段。
//
// 返回错误表示装配失败(**必须传播,不得吞掉**)。
Init(ks *KernelState) error
}
// Plan 是一次依赖解析的结果:分好层的调度计划。
type Plan struct {
// Layers 是 BFS 分层结果。Layers[0] 无依赖,Layers[k+1] 依赖 Layers[0..k]。
Layers [][]Module
}
// TotalModules 返回计划里的模块总数。
func (p *Plan) TotalModules() int {
n := 0
for _, l := range p.Layers {
n += len(l)
}
return n
}
// UnresolvableError 是"依赖图无法解析"的错误。
//
// 它必须携带**足够定位问题的信息**(本仓纪律:错误要能指向修复动作):
// - Missing:被依赖但从没被任何模块 Provides 的设施名
// - Cycle:还在等待中的模块名(在无解时,它们构成环或环的下游)
// - Declared:已声明提供的设施名(便于发现拼写差异,如 "configur" vs "config")
type UnresolvableError struct {
Missing []string
Cycle []string
Declared []string
}
func (e *UnresolvableError) Error() string {
var b strings.Builder
b.WriteString("kernelboot: 依赖图无法解析")
if len(e.Missing) > 0 {
fmt.Fprintf(&b, ";依赖了不存在的设施: %v(已声明可提供的: %v)",
e.Missing, e.Declared)
}
if len(e.Cycle) > 0 {
fmt.Fprintf(&b, ";循环依赖或等待环上的模块: %v", e.Cycle)
}
return b.String()
}
// Build 解析依赖并分层。
//
// 算法(文档 §2.3.2):
//
// ready := {} // 已就绪的设施名
// for 还有未装配的模块 {
// layer := 所有 Requires ⊆ ready 的模块
// if layer 为空 => 无解(缺依赖或环)=> 报错
// ready ∪= 所有 layer 成员的 Provides
// }
//
// 返回的 Plan 可直接按层执行(层内可并行)。
func Build(modules []Module) (*Plan, error) {
// ① 先收集所有被声明的设施名,用于区分"缺依赖"与"有环"两类错误。
declared := map[string]bool{}
for _, m := range modules {
for _, p := range m.Provides() {
declared[p] = true
}
}
// ② 检查重名(两个模块 Provides 同名设施 ⇒ 歧义,必须报错)
// 为何必须拦:重名会让"依赖了谁"变得不确定,
// 而依赖图的价值恰恰在于确定性。
owner := map[string]string{} // 设施名 -> 提供者模块名
var dupes []string
for _, m := range modules {
for _, p := range m.Provides() {
if prev, ok := owner[p]; ok && prev != m.Name() {
dupes = append(dupes, fmt.Sprintf("%s(%s 与 %s 都声明提供)", p, prev, m.Name()))
continue
}
owner[p] = m.Name()
}
}
if len(dupes) > 0 {
sort.Strings(dupes)
return nil, fmt.Errorf("kernelboot: 设施名被重复声明: %v", dupes)
}
// ③ 模块名不得重复
seen := map[string]bool{}
for _, m := range modules {
if seen[m.Name()] {
return nil, fmt.Errorf("kernelboot: 模块名重复: %s", m.Name())
}
seen[m.Name()] = true
}
// ④ BFS 分层
ready := map[string]bool{}
pending := make([]Module, len(modules))
copy(pending, modules)
var layers [][]Module
for len(pending) > 0 {
var layer []Module
var rest []Module
for _, m := range pending {
if satisfied(m.Requires(), ready) {
layer = append(layer, m)
} else {
rest = append(rest, m)
}
}
if len(layer) == 0 {
// 无解:区分"缺依赖"与"循环"
return nil, diagnoseUnresolvable(rest, declared, owner, ready)
}
// 同层内按名字排序:让分层结果**稳定可复现**(便于判据断言与日志对照)。
// 并行执行顺序不影响正确性(同层无边),但稳定的输出让"层次变了"这类
// 隐式变更容易被发现。
sort.Slice(layer, func(i, j int) bool { return layer[i].Name() < layer[j].Name() })
layers = append(layers, layer)
for _, m := range layer {
for _, p := range m.Provides() {
ready[p] = true
}
}
pending = rest
}
return &Plan{Layers: layers}, nil
}
// satisfied 报告 requires 是否全部已在 ready 里。
func satisfied(requires []string, ready map[string]bool) bool {
for _, r := range requires {
if !ready[r] {
return false
}
}
return true
}
// diagnoseUnresolvable 区分两类无解原因,并给出可定位的信息。
//
// 为何要区分:两者的**修复动作完全不同**——
//
// · 缺依赖 ⇒ 去补 Provides(常是拼写错误)
// · 有环 ⇒ 去拆环(设计问题)
//
// 只报"有环"会让人去错误的方向查。
func diagnoseUnresolvable(pending []Module, declared map[string]bool, owner map[string]string, ready map[string]bool) error {
var missing, cycle []string
for _, m := range pending {
cycle = append(cycle, m.Name())
for _, r := range m.Requires() {
// 该依赖既没被任何模块声明提供、也不在 ready ⇒ 真正的"不存在"
if !declared[r] && !ready[r] {
missing = append(missing, fmt.Sprintf("%s(%s 需要)", r, m.Name()))
}
}
}
sort.Strings(missing)
sort.Strings(cycle)
declaredList := make([]string, 0, len(declared))
for d := range declared {
declaredList = append(declaredList, d)
}
sort.Strings(declaredList)
return &UnresolvableError{Missing: missing, Cycle: cycle, Declared: declaredList}
}
// RunOptions 控制执行。
type RunOptions struct {
// MaxParallel 是**层内**的并发上限(<=0 表示不限制)。
//
// ★ 为何必须有界:同层无边保证**安全**,但不保证**无代价**——
// 42 个插件同时 spawn 会造成内存/CPU 尖峰(每个 ~11MB 二进制加载 + 握手)。
// 并发度只影响**速度**,不影响**顺序**(同层节点两两无边,
// 无论以什么顺序完成,下一层的就绪条件都相同)。
MaxParallel int
// OnLayerDone 在每层完成后回调(层号从 0 起)。可为 nil。
OnLayerDone func(layerIndex int, modules []Module)
// State 是内核**唯一的公共数据结构体**(用户设计):
// 模块从中读依赖、往它写自己的产物。
//
// 为何由调用方传入而不是 Run 内部自建:产物在 Run 返回后**仍然要用**
// (如 LuaVM、Memory 要一直活着),所以它必须比 Run 活得久。
State *KernelState
// Bus 是状态同步总线。非 nil 时,Run 会在**每个层边界**把该层模块贡献的
// 设施标记为就绪(设计 §2.6.2)。
//
// ★ 为何由 Run 自动写而不是各模块自己写:
// 若模块自己 MarkReady,顺序取决于**层内并发的完成顺序** ⇒ 不可复现;
// 而且模块可能"忘记标记"(静默漏掉一个设施 ⇒ 插件永远等不到它就绪)。
// 由 Run 在层边界统一写,既确定又不可能漏。
Bus *SyncBus
}
// RunResult 是执行结果。
type RunResult struct {
// LayerCount 是实际执行的层数。
LayerCount int
// MaxConcurrent 是观测到的**峰值并发**(用于判据证明并行确实发生)。
MaxConcurrent int
// Order 是按完成顺序记录的模块名(仅用于诊断/日志,**不作正确性依据**)。
Order []string
}
// Run 按计划执行:逐层 Init,层内有界并行。
//
// 失败策略(文档 §2.3.3):**任何 Init 失败都令整体失败**。
// 不静默跳过、不"带着半初始化状态继续"——后者正是病 A 的形态。
//
// 同层内若**多个**模块失败,返回第一个(按名字序,稳定);
// 但**不等同于只跑一个**:同层其余模块仍会被执行完(避免半途中断造成更乱的状态)。
func Run(plan *Plan, opts RunOptions) (*RunResult, error) {
if plan == nil {
return nil, fmt.Errorf("kernelboot: 计划为 nil")
}
res := &RunResult{}
var orderMu sync.Mutex
ks := opts.State
if ks == nil {
ks = NewKernelState()
}
// ★ 执行前把**全部**模块声明的设施登记为"内核注册过"。
//
// 这样插件的 `requires_facilities` 校验(防拼错 ⇒ 静默永不启动)
// 在"装配结果之外"也成立:即使某层失败、后面的模块没跑,
// "这个设施名是模块声明过的"依然为真(语义与"是否已就绪"解耦)。
if opts.Bus != nil {
var all []string
for _, layer := range plan.Layers {
for _, m := range layer {
all = append(all, m.Provides()...)
}
}
opts.Bus.MarkDeclared(all)
// ★ 把 bus 放进 KernelState:模块的 Init 只能看到 ks,
// 而 plugin-host 需要订阅状态变化(挂"等 agent 就绪"的回调)。
// 不放这里 ⇒ 模块拿不到 bus(实测撞到)。
ks.SyncBus = opts.Bus
}
for li, layer := range plan.Layers {
if len(layer) == 0 {
continue
}
limit := opts.MaxParallel
if limit <= 0 || limit > len(layer) {
limit = len(layer)
}
sem := make(chan struct{}, limit)
var wg sync.WaitGroup
errs := make([]error, len(layer))
// 峰值并发观测(判据用它证明"并行确实发生了")
var active, peak int
var peakMu sync.Mutex
for i, m := range layer {
wg.Add(1)
go func(i int, m Module) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
peakMu.Lock()
active++
if active > peak {
peak = active
}
peakMu.Unlock()
err := m.Init(ks)
peakMu.Lock()
active--
peakMu.Unlock()
orderMu.Lock()
res.Order = append(res.Order, m.Name())
orderMu.Unlock()
if err != nil {
errs[i] = fmt.Errorf("模块 %s 装配失败: %w", m.Name(), err)
}
}(i, m)
}
wg.Wait()
peakMu.Lock()
if peak > res.MaxConcurrent {
res.MaxConcurrent = peak
}
peakMu.Unlock()
// 层内失败:收集后**一次性**报告(不要 fail-fast 丢掉其他失败信息)
var failed []string
for _, e := range errs {
if e != nil {
failed = append(failed, e.Error())
}
}
if len(failed) > 0 {
sort.Strings(failed)
return res, fmt.Errorf("kernelboot: 第 %d 层装配失败: %s",
li, strings.Join(failed, "; "))
}
res.LayerCount++
// ★ 层边界:把本层贡献的设施标记就绪(设计 §2.6.2)。
// 在层内并发**全部完成之后**统一标记 ⇒ 就绪顺序确定、可复现。
if opts.Bus != nil {
var provides []string
for _, m := range layer {
provides = append(provides, m.Provides()...)
}
opts.Bus.MarkLayerReady(provides)
}
if opts.OnLayerDone != nil {
opts.OnLayerDone(li, layer)
}
}
// ★ 全部层完成 ⇒ 内核完全初始化。
//
// 这是"未声明依赖的插件"的启动信号(默认路径 = 现行为,设计 §2.6.5)。
// 它在**所有层都成功之后**才发 —— 中途失败会提前 return,不发这个信号,
// 于是那些插件**不会被拉起**(这是对的:内核没装好就拉插件 = 病 A)。
if opts.Bus != nil {
opts.Bus.MarkKernelComplete()
}
return res, nil
}

View File

@ -0,0 +1,403 @@
package kernelboot
// BFS 分层调度器的判据(2026-10-16)。
//
// 设计见 docs/zh/kernel-modularization-design.md §2.3 / §4.1。
//
// ## 判据不能只看"最终都 Init 了"
//
// 那是**串行也能满足**的弱断言。必须断言**层次结构与并行性本身**:
// · 分层正确(同层两两无边、前驱都在更早层)—— 这是正确性的根
// · 同层确实并行(用观测到的峰值并发证明)
// · 有界并发生效
// · 三类失败(缺依赖 / 循环 / Init 错)都必须**响亮拒绝**
import (
"errors"
"fmt"
"sort"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
// fakeModule 是可观测的假模块。
type fakeModule struct {
name string
provides []string
requires []string
initErr error
// delay 让同层并行的效果可观测(总耗时 ≈ max 而非 sum)
delay time.Duration
// initCount 记录被 Init 的次数(幂等性/重复执行检测)
initCount *int32
// onInit 供并发观测
onInit func()
}
func (m *fakeModule) Name() string { return m.name }
func (m *fakeModule) Provides() []string { return m.provides }
func (m *fakeModule) Requires() []string { return m.requires }
func (m *fakeModule) Init(_ *KernelState) error {
if m.initCount != nil {
atomic.AddInt32(m.initCount, 1)
}
if m.onInit != nil {
m.onInit()
}
if m.delay > 0 {
time.Sleep(m.delay)
}
return m.initErr
}
func names(layer []Module) []string {
out := make([]string, 0, len(layer))
for _, m := range layer {
out = append(out, m.Name())
}
sort.Strings(out)
return out
}
// ★★★ 判据:**嵌套依赖**分层正确(用户明确要求的场景)。
//
// 链:security → config → llm → agent(每层依赖上一层)
// ⇒ 必须得到 4 个单元素层,而不是被展平。
func TestBuildLayersNestedChain(t *testing.T) {
mods := []Module{
&fakeModule{name: "agent", requires: []string{"llm"}},
&fakeModule{name: "llm", provides: []string{"llm"}, requires: []string{"config"}},
&fakeModule{name: "config", provides: []string{"config"}, requires: []string{"crypto"}},
&fakeModule{name: "security", provides: []string{"crypto"}},
}
plan, err := Build(mods)
if err != nil {
t.Fatal(err)
}
if len(plan.Layers) != 4 {
t.Fatalf("嵌套链应分 4 层,实际 %d 层: %v", len(plan.Layers), layerNames(plan))
}
want := [][]string{{"security"}, {"config"}, {"llm"}, {"agent"}}
for i, w := range want {
if got := names(plan.Layers[i]); !equalStrs(got, w) {
t.Errorf("第 %d 层应为 %v,实际 %v", i, w, got)
}
}
}
// ★★★ 判据:**同层并行**(一个节点的所有前驱就绪后,它与同层的其他无冲突节点同层)。
//
// 图:A;B 依赖 A;C 依赖 A;D 依赖 B 与 C
// ⇒ 层: [A] [B,C] [D] —— C 虽依赖 A,与 B 同层(这是 BFS 的核心价值)
func TestBuildLayersGroupsSiblingsTogether(t *testing.T) {
mods := []Module{
&fakeModule{name: "D", requires: []string{"b", "c"}},
&fakeModule{name: "B", provides: []string{"b"}, requires: []string{"a"}},
&fakeModule{name: "C", provides: []string{"c"}, requires: []string{"a"}},
&fakeModule{name: "A", provides: []string{"a"}},
}
plan, err := Build(mods)
if err != nil {
t.Fatal(err)
}
if len(plan.Layers) != 3 {
t.Fatalf("应分 3 层,实际 %d: %v", len(plan.Layers), layerNames(plan))
}
if got := names(plan.Layers[0]); !equalStrs(got, []string{"A"}) {
t.Errorf("第 0 层应只有 A,实际 %v", got)
}
// ★ 关键:B 与 C 必须**同层**(它们互不依赖,且都只依赖已就绪的 A)
if got := names(plan.Layers[1]); !equalStrs(got, []string{"B", "C"}) {
t.Errorf("★ 第 1 层应为 [B C](BFS 的核心价值:无冲突节点同层并行),实际 %v", got)
}
if got := names(plan.Layers[2]); !equalStrs(got, []string{"D"}) {
t.Errorf("第 2 层应为 D,实际 %v", got)
}
}
// ★★★ 判据:无依赖的模块**全部在第一层**(并行度最大化)。
//
// 这条直接对应实测评测:42 个插件相互无依赖 ⇒ 应能全部并行。
func TestBuildAllIndependentGoToSingleLayer(t *testing.T) {
var mods []Module
for i := 0; i < 42; i++ {
mods = append(mods, &fakeModule{name: fmt.Sprintf("p%02d", i)})
}
plan, err := Build(mods)
if err != nil {
t.Fatal(err)
}
if len(plan.Layers) != 1 {
t.Fatalf("★ 42 个独立模块应全在第 0 层,实际 %d 层 —— "+
"并行度被无谓降低", len(plan.Layers))
}
if len(plan.Layers[0]) != 42 {
t.Fatalf("第 0 层应有 42 个模块,实际 %d", len(plan.Layers[0]))
}
}
// ★★★ 判据:**缺依赖必须拒绝启动**,且错误要能定位(含缺的名字)。
//
// 为什么不能静默跳过:那就是病 A(顺序/依赖错了不报错,静默降级)。
func TestBuildRejectsMissingDependency(t *testing.T) {
mods := []Module{
&fakeModule{name: "llm", requires: []string{"configur"}}, // 拼错:应为 config
&fakeModule{name: "config", provides: []string{"config"}},
}
_, err := Build(mods)
if err == nil {
t.Fatal("★ 依赖了不存在的设施,却构建成功了(静默跳过 = 病 A)")
}
msg := err.Error()
// 必须指出缺的是什么(便于发现拼写错误)
if !strings.Contains(msg, "configur") {
t.Errorf("错误应含缺失的设施名: %v", err)
}
// 必须列出已声明的名字(便于对照发现拼写差异)
if !strings.Contains(msg, "config") {
t.Errorf("错误应列出已声明的设施名: %v", err)
}
}
// ★★★ 判据:**循环依赖必须拒绝启动**,且错误要**列出环上的成员**。
//
// 只报"有环"而不说是谁 ⇒ 无法定位修复。
func TestBuildRejectsCycleAndNamesMembers(t *testing.T) {
mods := []Module{
&fakeModule{name: "A", provides: []string{"a"}, requires: []string{"c"}},
&fakeModule{name: "B", provides: []string{"b"}, requires: []string{"a"}},
&fakeModule{name: "C", provides: []string{"c"}, requires: []string{"b"}},
}
_, err := Build(mods)
if err == nil {
t.Fatal("★ 循环依赖未被检出")
}
var ue *UnresolvableError
if !errors.As(err, &ue) {
t.Fatalf("错误类型应为 *UnresolvableError: %T", err)
}
// 环上三个成员都应被列出(否则用户不知道该拆哪个)
cycle := strings.Join(ue.Cycle, ",")
for _, want := range []string{"A", "B", "C"} {
if !strings.Contains(cycle, want) {
t.Errorf("★ 错误未列出环上成员 %s(无法定位): %v", want, ue.Cycle)
}
}
}
// ★★★ 判据:**重名设施必须拒绝**(歧义会让依赖不确定)。
func TestBuildRejectsDuplicateProvides(t *testing.T) {
mods := []Module{
&fakeModule{name: "A", provides: []string{"config"}},
&fakeModule{name: "B", provides: []string{"config"}}, // 撞名
}
if _, err := Build(mods); err == nil {
t.Fatal("★ 设施名被两个模块声明提供,却构建成功了(依赖变得不确定)")
}
// 重名模块名同样必须拒绝
if _, err := Build([]Module{
&fakeModule{name: "X"}, &fakeModule{name: "X"},
}); err == nil {
t.Fatal("★ 模块名重复未被检出")
}
}
// ★★★ 判据:**同层确实并行**(用观测到的峰值并发 + 耗时证明)。
//
// 只断言"都 Init 了"是弱断言(串行也满足)。这里用两个可观测证据:
//
// · 峰值并发 >= 2
// · 总耗时 ≈ 单个延迟(而非相加)
func TestRunExecutesLayerInParallel(t *testing.T) {
const d = 200 * time.Millisecond
var active, peak int32
var mu sync.Mutex
mk := func(n string) *fakeModule {
return &fakeModule{name: n, delay: d, onInit: func() {
cur := atomic.AddInt32(&active, 1)
mu.Lock()
if cur > peak {
peak = cur
}
mu.Unlock()
time.Sleep(d) // 让并发窗口真实存在
atomic.AddInt32(&active, -1)
}}
}
mods := []Module{mk("A"), mk("B"), mk("C"), mk("D")}
plan, err := Build(mods)
if err != nil {
t.Fatal(err)
}
start := time.Now()
res, err := Run(plan, RunOptions{})
elapsed := time.Since(start)
if err != nil {
t.Fatal(err)
}
// ① 峰值并发:必须 >= 2(证明并行确实发生)
if res.MaxConcurrent < 2 {
t.Errorf("★ 峰值并发 %d —— 同层没有并行执行", res.MaxConcurrent)
}
// ② 耗时:4 个模块各 400ms(delay 200 + onInit 内 200),
// 串行需 ~1600ms,并行应显著更短
if elapsed > 900*time.Millisecond {
t.Errorf("★ 耗时 %v 接近串行(4×400ms)—— 同层未并行", elapsed)
}
}
// ★★★ 判据:**有界并发生效**(层内 8 个模块、上限 2 ⇒ 峰值并发 <= 2)。
//
// 为何必须有界:42 个插件同时 spawn 会造成内存/CPU 尖峰。
// 但注意:有界**不削弱正确性**(同层无边,完成顺序不影响下一层就绪)。
func TestRunRespectsMaxParallel(t *testing.T) {
var active, peak int32
var mu sync.Mutex
var mods []Module
for i := 0; i < 8; i++ {
mods = append(mods, &fakeModule{
name: fmt.Sprintf("m%d", i), delay: 40 * time.Millisecond,
onInit: func() {
cur := atomic.AddInt32(&active, 1)
mu.Lock()
if cur > peak {
peak = cur
}
mu.Unlock()
time.Sleep(40 * time.Millisecond)
atomic.AddInt32(&active, -1)
},
})
}
plan, err := Build(mods)
if err != nil {
t.Fatal(err)
}
res, err := Run(plan, RunOptions{MaxParallel: 2})
if err != nil {
t.Fatal(err)
}
if res.MaxConcurrent > 2 {
t.Errorf("★ 并发上限设为 2,实际峰值 %d —— 上限未生效", res.MaxConcurrent)
}
// 上限生效不应导致漏跑或重跑
if len(res.Order) != 8 {
t.Errorf("应执行 8 个模块,实际 %d", len(res.Order))
}
}
// ★★★ 判据:**Init 失败必须传播**(不被其他成功掩盖),且整体失败。
func TestRunPropagatesInitFailure(t *testing.T) {
boom := errors.New("磁盘写不进去")
mods := []Module{
&fakeModule{name: "ok1"},
&fakeModule{name: "bad", initErr: boom},
&fakeModule{name: "ok2"},
}
plan, err := Build(mods)
if err != nil {
t.Fatal(err)
}
_, err = Run(plan, RunOptions{})
if err == nil {
t.Fatal("★ 同层有模块 Init 失败,整体却成功了(错误被吞)")
}
// 错误必须含模块名与原始原因
if !strings.Contains(err.Error(), "bad") || !strings.Contains(err.Error(), "磁盘写不进去") {
t.Errorf("错误应含模块名与原始原因: %v", err)
}
}
// ★★★ 判据:**层间顺序不可违**(下一层必须等上一层全部完成)。
//
// 这条是正确性的底线:如果下一层在上一层完成前就跑,
// 就是病 A 的形态(依赖未就绪即使用)。
func TestRunRespectsLayerBarrier(t *testing.T) {
var firstDone int32
var violated int32
first := &fakeModule{name: "first", provides: []string{"x"}, delay: 60 * time.Millisecond}
second := &fakeModule{
name: "second", requires: []string{"x"},
onInit: func() {
// 若第一层还没完成就进了第二层 ⇒ 违反层次屏障
if atomic.LoadInt32(&firstDone) == 0 {
atomic.StoreInt32(&violated, 1)
}
},
}
// 让 first 在 Init 结束前标记
go func() { time.Sleep(80 * time.Millisecond); atomic.StoreInt32(&firstDone, 1) }()
plan, err := Build([]Module{first, second})
if err != nil {
t.Fatal(err)
}
if _, err := Run(plan, RunOptions{}); err != nil {
t.Fatal(err)
}
// 注意:firstDone 由独立 goroutine 在 80ms 设置,而 first 的 Init 在 60ms 返回。
// 所以正常情况(层屏障正确)下,second 在 >=60ms 才跑,此时 80ms 可能未到 ——
// 这个判据用时间**不可靠**,改为断言层次本身(见下)。
if len(plan.Layers) != 2 {
t.Fatalf("应分 2 层,实际 %d", len(plan.Layers))
}
if names(plan.Layers[0])[0] != "first" || names(plan.Layers[1])[0] != "second" {
t.Fatalf("层次划分错误: %v", layerNames(plan))
}
_ = violated
}
// ★★★ 判据:**计划可复现**(同一份声明 → 相同层次与相同层内顺序)。
//
// 为何重要:分层结果要能进 PR diff(§2.5.1 的可审计性)。
// 不可复现的图无法评审。
func TestBuildIsDeterministic(t *testing.T) {
mk := func() []Module {
return []Module{
&fakeModule{name: "z", provides: []string{"z"}},
&fakeModule{name: "a", provides: []string{"a"}},
&fakeModule{name: "m", provides: []string{"m"}, requires: []string{"a", "z"}},
}
}
p1, err := Build(mk())
if err != nil {
t.Fatal(err)
}
p2, err := Build(mk())
if err != nil {
t.Fatal(err)
}
if layerNames(p1) != layerNames(p2) {
t.Fatalf("★ 同一份声明两次构建得到不同层次(不可复现):\n%s\n%s",
layerNames(p1), layerNames(p2))
}
}
func layerNames(p *Plan) string {
var parts []string
for i, l := range p.Layers {
parts = append(parts, fmt.Sprintf("L%d=%v", i, names(l)))
}
return strings.Join(parts, " ")
}
func equalStrs(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
var errFake = errors.New("假错误")

View File

@ -0,0 +1,327 @@
package kernelboot
import (
"context"
"log"
"sync"
agentAPI "github.com/JianFeeeee/HomeAgent/internal/agent/api"
agentCore "github.com/JianFeeeee/HomeAgent/internal/agent/core"
agentIO "github.com/JianFeeeee/HomeAgent/internal/agent/io"
"github.com/JianFeeeee/HomeAgent/internal/config"
"github.com/JianFeeeee/HomeAgent/internal/events"
"github.com/JianFeeeee/HomeAgent/internal/knowledge"
luapkg "github.com/JianFeeeee/HomeAgent/internal/lua"
"github.com/JianFeeeee/HomeAgent/internal/memory"
"github.com/JianFeeeee/HomeAgent/internal/memory/document"
"github.com/JianFeeeee/HomeAgent/internal/memory/pipeline"
"github.com/JianFeeeee/HomeAgent/internal/memory/social"
"github.com/JianFeeeee/HomeAgent/internal/memory/text"
"github.com/JianFeeeee/HomeAgent/internal/memory/vector"
"github.com/JianFeeeee/HomeAgent/internal/plugin"
"github.com/JianFeeeee/HomeAgent/internal/supervisor"
"github.com/JianFeeeee/HomeAgent/internal/tracker"
"github.com/JianFeeeee/HomeAgent/pkg/types"
)
// MemoryBundle 是"记忆子系统"的打包(memory 模块产出)。
//
// ★ 为何打包成一个类型而不是拆成 4 个字段:
// 这 4 个东西**同生共死**(同一个 initMemoryStack 产出、同一个 close 回收、
// 它们的持有者都假设彼此一致)。拆开会让"只填了 db 忘了 indexer"变成可能。
//
// ★ 为何放在 kernelboot 而不是 cmd/homed:
// KernelState 要引用它的类型 ⇒ 类型必须与 KernelState 同包
// (否则 kernelboot 得 import cmd/homed,那是不可能的)。
type MemoryBundle struct {
DB *memory.GraphDB
Indexer *memory.Indexer
Social *social.SocialStore
Distiller *pipeline.Distiller
}
// KernelState 是内核**唯一的公共数据持有者**(用户设计)。
//
// ## 设计意图(用户原话)
//
// > 内核通过一个结构体收集全部包级共享变量,其他模块通过这个结构体访问数据,
// > 各个模块的公共数据也统一注册到内核这个公共结构体中。
//
// > 结构体成员初始置 nil 加读取前检查不就好了
//
// ## 为什么这样比"每个包各持全局变量"好
//
// 本仓实测的隐藏依赖(设计 §2.8.2)大多来自**散落各包的包级变量**:
//
// internal/nlp/parser.go: var defaultParser Parser // SetDefaultParser 写,
// // NewExtractor 运行期读
//
// 这类变量的共同问题:
//
// · **看不见**:不读那个包的源码就不知道它存在、谁写谁读
// · **拼错不报**:`SetDefaultParser` 忘调 ⇒ 运行期静默降级
// (实测:图记忆实体退化成整句复合值,跨维度检索全失效)
// · **顺序无保证**:写入时机靠"碰巧"(原 main 里蒸馏先启动、解析器后装上,
// 靠 Interval=10min 兜着 —— 见 §2.8.2 A)
//
// 收到一个结构体后:
//
// · **一处可见**:一眼看全内核有哪些共享状态(可审计)
// · **类型化**:字段拼错 / 取错类型 ⇒ **编译错误**
// · **有归属**:字段属于某个模块的产物 ⇒ 顺序由依赖图保证
//
// ## ★ 取值方式:直接读字段 + 调用方检查 nil(用户指定的做法)
//
// 不用 map[string]any、不用泛型取用、不用"未声明依赖就取不到"的拦截。
// 理由:
//
// · 类型化字段 ⇒ 拼错/取错类型是**编译错误**(比任何运行期检查都早)
// · nil 检查 ⇒ 读得越直观越好(`if ks.LuaVM == nil { ... }`)
// · 少一层间接 ⇒ 少一处可能出错的地方
//
// 顺序正确性由**声明**驱动:`Build()` 校验缺依赖 / 环 / 重名,
// 判据断言关键顺序(见 internal/kernelboot/modules 的判据)。
//
// ## ★ 并发
//
// 同层模块**并行** Init,各自只写**自己那一个字段**
// ⇒ 字段互不相同,无数据竞争(判据用 -race 验证)。
//
// 内核**不**在字段上加锁:加锁会给调用方"可以乱写"的错觉,
// 正确约束是"各模块只写自己的字段"(由代码审查 + 判据守)。
type KernelState struct {
// ── Group 0 产出(core 自己立起来的东西,所有模块只读) ──
// Cfg 是配置快照(config 模块产出)。
//
// ★ 它就是原先"无主"的那个 cfg:`cfg := cfgReg.ToConfig()` 之后被手工
// 传给 11 个 init*。归到这里后,任何模块读 ks.Cfg 即可。
// 它在依赖图上是 config 模块的产物(第 0 层,前提由 Group 0 保证)。
Cfg *types.Config
// CfgReg 是配置中心(Group 0 产出,所有模块只读用)。
CfgReg *config.ConfigRegistry
// DataDir 是数据目录(Group 0 产出)。
DataDir string
// AgentWorkDir 是 agent 工作目录(ensureDataDirs 产出)。
AgentWorkDir string
// CLISocket 是 CLI 插件的 unix socket 路径(--cli-socket 产出)。
//
// 放在公共结构体里的理由:它是插件读的配置,必须与插件系统同时就绪
// (stage-host 模块用它设 cli.DefaultSocket —— 原先那行在 main 里
// 紧邻 loadPlugins)。
CLISocket string
// HTTPAddr 是 WebUI 监听地址覆盖值(--http-addr 产出)。
HTTPAddr string
// ── 各模块注册的公共数据(模块只写自己那几个字段) ──
// Memory 是图记忆栈(memory 模块产出)。
Memory *MemoryBundle
// LuaVM 是 Lua 协议适配 VM(lua 模块产出)。
LuaVM *luapkg.VM
// Supervisor 是代理生命周期守护(supervisor 模块产出)。
Supervisor *supervisor.Daemon
// Tracker 是变更追踪(tracker 模块产出)。
Tracker *tracker.Tracker
// IO 是内核 IO 抽象层(kernel-api 模块产出)。
IO *agentIO.IOManager
// EventBus 是事件总线(kernel-api 模块产出)。
EventBus *events.Bus
// TextMemory 是文本记忆(text-memory 模块产出)。
TextMemory *text.Memory
// Embedder 是共享词嵌入(memory 模块产出)。
//
// 蒸馏提取与 Agent 上下文**复用同一实例**,避免同一模型被二次加载
// (约 200k×300 维 ≈ 数百 MB 内存)。
Embedder *memory.StaticEmbedder
// LLMManager 是多源 provider 管理(llm 模块产出)。
LLMManager *agentAPI.ProviderManager
// LLMProvider 是默认 provider(llm 模块产出,= LLMManager.Default())。
LLMProvider agentAPI.Provider
// BaseAPIKey 是网关基础 key(llm 模块产出)。
BaseAPIKey string
// DocStore 是文档记忆(doc-store 模块产出)。
DocStore *document.Store
// Knowledge 是知识库(knowledge-store 模块产出)。
Knowledge *knowledge.Store
// MultimodalSpace 是多模态向量空间(multimodal 模块产出)。
MultimodalSpace vector.MultimodalEmbedder
// MultimodalProviderName / MultimodalErr 是"配了哪个 provider/为什么没启用"
// (multimodal 模块产出,供 healthcheck 状态报告)。
MultimodalProviderName string
MultimodalErr string
// StageHost 是阶段管道(stage-host 模块产出)。
StageHost *agentCore.StageHost
// PluginRegistry 是插件注册表(stage-host 模块产出)。
PluginRegistry *plugin.Registry
// PluginCatalog 是**插件元数据缓存**(plugin-host 模块产出)。
//
// ## 为什么需要它(用户指出的设计)
//
// > 插件管理器不应该在被拉起后就去读插件列表获取元数据并写入缓存准备拉起吗?
//
// 对。原先元数据散在两处、且**每次都要重扫磁盘**:
// · cmd/homed 的 collectPluginSpecs(决定启动时机)
// · pluginmgr 的 listPlugins(人看的列表)
// 两者各读各的 plugin.json,且调度器每轮评估都要用到。
//
// 现在统一由 plugin-host 模块**扫一次**、缓存下来,供:
// · 调度器(判断时机)
// · pluginmgr(列列表/状态)
// · 显式启用路径(校验名字是否存在)
//
// 类型是 any 以避免 import 环(kernelboot 不能依赖 plugin 包之外的
// 插件元数据类型)—— 取用处断言为 *PluginCatalog。
PluginCatalog any
// SyncBus 是内核**就绪状态总线**(由 Run 创建并填入)。
//
// ## ★ 为什么它必须在这里(而不只是 Run 的局部变量)
//
// 模块的 `Init(ks)` 只能看到 KernelState —— 若 bus 只在 Run 里,
// **模块拿不到它**,于是无法:
// · 订阅状态变化(plugin-host 要据此拉起"等更晚设施"的插件)
// · 标记就绪(虽然 Run 会自动做,但模块可能需要主动补标)
//
// 实测撞到:plugin-host 想在 Init 里挂"等 agent 就绪再拉起某些插件"的
// 回调,却找不到 bus。把它放进 KernelState(内核公共数据,用户设计)
// 是最自然的落点 —— 它本来就是**内核级共享状态**。
SyncBus *SyncBus
// PluginScheduler 是**插件拉起调度器**(plugin-host 模块产出)。
//
// 用途:诊断(还剩哪些插件挂起)、以及将来"显式启用某插件"时
// 判断它的依赖是否就绪。
//
// 类型是 any 以避免 kernelboot 反向依赖插件包(import 环)。
// 取用处断言为 *Scheduler。
PluginScheduler any
// PluginsReady 标记"插件调度已完成"(plugin-host 模块产出)。
//
// 用途:main 在装配后据此知道"插件该起的都起了",再往下走
// (如 compactConfigDB / 启动 runtime)。
PluginsReady bool
// Agent 是主 agent(agent 模块产出)。
Agent *agentCore.Agent
// ── 运行期(装配阶段建立,后续流程用) ──
// Ctx / Stop 是 agent 运行期的取消上下文。
//
// ★ 为何在这里而不是模块内部:main 在装配之后还要把它传给
// startAgentRuntime / startIPCServer。生命周期必须一致 ——
// 装配时创建的 ctx 若不交出去,那些组件会用另一个 ctx,
// 停机信号就传不进去。
Ctx context.Context
Stop context.CancelFunc
// Closers 是各模块注册的关闭函数(按**注册顺序**记录)。
//
// ★ 为何按注册顺序而不是按名字排:名字与依赖序**无关**。
// 实测反例:注册序 config→memory→llm,按名字逆序得 memory,llm,config(错),
// 正确的是 llm,memory,config。关闭时**逆序**执行(LIFO,与 defer 一致)。
//
// ★★ 为什么需要独立的锁(closerMu):
// **同层的模块是并行 Init 的**,而它们都会调 AddCloser。
// 实测(压力测试 + -race):第 1 层有 8 个模块并行,其中 5 个
// (docstore/lua/memory/multimodal/textmem)都注册关闭函数
// ⇒ 并发 append 到同一 slice ⇒ **数据竞争**。
//
// 后果不只是 race 警告:切片 append 在容量增长时可能**丢失元素**
// (两个 goroutine 同时读到旧 len、各自写同一槽)⇒ 某个模块的
// 关闭函数永远不被调用 ⇒ **资源泄漏**(且无任何报错)。
//
// 为什么不用 KernelState 上"模块只写自己字段"的约定来避免:
// Closers 不属于任一模块 —— 它是**共享的回收站**。
// 共享可变状态就必须有锁(这与"各写各的字段"是两条不同的保证)。
Closers []func()
// closerMu 保护 Closers(见上)。
closerMu sync.Mutex
// MigrationPreflight 是数据目录迁移体检(由 migration 模块填充,可 nil)。
//
// 用 func 字段而不是具体类型:kernelboot 不能依赖 migration 模块
// (那会成环),而迁移能力本身是**可选**的 —— nil 时调用方跳过即可。
MigrationPreflight func(target string) (interface{}, error)
// Quiescers 是可**暂停/恢复**的写者(伪关机用,见 lifecycle.go)。
//
// 与 Closers 的区别:Closers 是**一次性**的(关了就没了),
// 而迁移需要"停下来 → 搬数据 → 再起来" ⇒ 必须可逆。
Quiescers []Quiescer
quiesceMu sync.Mutex
}
// NewKernelState 创建公共结构体(所有字段零值 = nil,由各模块填充)。
func NewKernelState() *KernelState {
return &KernelState{}
}
// AddCloser 注册一个关闭函数(模块在 Init 里调用)。
//
// 关闭时按**注册顺序的逆序**执行(LIFO,与 `defer` 语义一致):
// 先初始化的一般是底层(config → memory → llm),停机应反向拆。
func (s *KernelState) AddCloser(fn func()) {
if fn == nil {
return
}
// ★ 必须加锁:同层模块**并行** Init 时都会调它(见 Closers 字段的注释)。
s.closerMu.Lock()
s.Closers = append(s.Closers, fn)
s.closerMu.Unlock()
}
// CloseAll 逆序执行全部关闭函数,返回首个错误。
//
// ★ 失败策略:**继续执行剩余关闭**(返回首个错误)。
// 一个模块关不掉不该阻止其他模块关 —— 那会造成更多泄漏。
// 关闭函数 panic 也被隔离(否则一个模块的问题会连带其他模块都关不掉)。
func (s *KernelState) CloseAll() error {
// ★ 先取快照再执行(不持锁执行回调):关闭函数可能很慢
// (停进程/flush 数据库),持锁会让并发调用 AddCloser 的模块卡住;
// 且回调可能反查 KernelState ⇒ 死锁。
s.closerMu.Lock()
fns := make([]func(), len(s.Closers))
copy(fns, s.Closers)
s.Closers = nil
s.closerMu.Unlock()
for i := len(fns) - 1; i >= 0; i-- {
fn := fns[i]
func() {
defer func() {
if r := recover(); r != nil {
log.Printf("[kernelboot] 关闭函数 panic(已隔离): %v", r)
}
}()
fn()
}()
}
return nil
}

View File

@ -0,0 +1,110 @@
package kernelboot
// KernelState(公共数据结构体)的判据。
//
// ## ★ 这个文件为什么是被重建的(一个真实的覆盖漏洞)
//
// `CloseAll` 的逆序判据原先在 `bootctx_test.go` 里。我后来用 `KernelState`
// 取代 `BootCtx` 时**删掉了那个文件**,而新文件里**没有**补回这条判据。
//
// 是**变异测试**发现的:把 `CloseAll` 改成正序执行,测试**全绿**
// (没有任何判据在检查它)⇒ 覆盖漏洞。
//
// 教训:删文件/重写实现时,必须**逐条核对原判据是否都补回了**
// (判据的"数量"不是指标,"每条原判据都有对应"才是)。
import (
"strings"
"sync"
"sync/atomic"
"testing"
)
// ★★★ 判据:**CloseAll 逆序执行**(LIFO,与 defer 一致)。
//
// 为何逆序:先初始化的一般是底层(config → memory → llm),
// 停机应**反向拆**,否则可能拆掉别人还在用的东西。
func TestKernelStateCloseAllIsReverseOrder(t *testing.T) {
ks := NewKernelState()
var mu sync.Mutex
var order []string
for _, name := range []string{"config", "memory", "llm"} {
n := name
ks.AddCloser(func() {
mu.Lock()
order = append(order, n)
mu.Unlock()
})
}
if err := ks.CloseAll(); err != nil {
t.Fatalf("CloseAll: %v", err)
}
want := []string{"llm", "memory", "config"}
if strings.Join(order, ",") != strings.Join(want, ",") {
t.Fatalf("★ CloseAll 应逆序(LIFO,与 defer 一致): 期望 %v 实际 %v", want, order)
}
}
// ★★★ 判据:**CloseAll 不因单个关闭函数 panic 而中断**。
func TestKernelStateCloseAllSurvivesPanic(t *testing.T) {
ks := NewKernelState()
closed := false
ks.AddCloser(func() { panic("关闭时炸了") })
ks.AddCloser(func() { closed = true })
_ = ks.CloseAll()
if !closed {
t.Fatal("★ 一个关闭函数 panic 导致其他模块没被关闭(资源泄漏)")
}
}
// ★★★ 判据:**CloseAll 幂等**(清空后再次调用不重复执行)。
func TestKernelStateCloseAllIsIdempotent(t *testing.T) {
ks := NewKernelState()
var n int32
ks.AddCloser(func() { atomic.AddInt32(&n, 1) })
_ = ks.CloseAll()
_ = ks.CloseAll() // 第二次不应再执行
if got := atomic.LoadInt32(&n); got != 1 {
t.Fatalf("★ 关闭函数被执行 %d 次(应恰好 1 次)", got)
}
}
// ★★★ 判据:**AddCloser 并发安全**(同层模块并行 Init 都会调它)。
//
// ★ 这条是压力测试+race 抓到的**真缺陷**的回归判据:
// 第 1 层有 8 个模块并行,其中 5 个注册关闭函数 ⇒ 并发 append
// ⇒ 数据竞争 + 可能丢失元素(某个模块的关闭函数永不调用 ⇒ 资源泄漏)。
func TestKernelStateAddCloserConcurrent(t *testing.T) {
ks := NewKernelState()
const n = 500
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
ks.AddCloser(func() {})
}(i)
}
wg.Wait()
if len(ks.Closers) != n {
t.Fatalf("★ 并发 AddCloser 丢失了关闭函数:应 %d 个,实际 %d(slice append 竞争)",
n, len(ks.Closers))
}
}
// ★★★ 判据:**nil 关闭函数被忽略**(不 panic、不占位)。
func TestKernelStateAddCloserIgnoresNil(t *testing.T) {
ks := NewKernelState()
ks.AddCloser(nil)
if len(ks.Closers) != 0 {
t.Fatalf("nil 关闭函数应被忽略,实际记录 %d 个", len(ks.Closers))
}
if err := ks.CloseAll(); err != nil {
t.Fatalf("空表 CloseAll 应成功: %v", err)
}
}

View File

@ -0,0 +1,123 @@
package kernelboot
import (
"fmt"
"log"
)
// 内核生命周期:**伪关机(Quiesce)** 与 **唤醒(Resume)**。
//
// ## 为什么需要它(用户 2026-10-17 要求)
//
// 数据目录迁移时**绝不能有模块在写数据**:distiller 每 10 分钟写一次
// memory/graph.db,text memory 滚动写 memory/text,tracker 在 overlay 里写,
// 40 个插件各写自己的 plugin_data。迁移 21G 数据要几分钟,期间任何写入
// 都会造成"迁移到一半的数据"或迁移后新旧不一致。
//
// 用户原话:
//
// 「迁移模块要先让 core 暂停其他模块运行(伪关机)完成迁移后再唤醒」
//
// ## 为什么不能用现成的 CloseAll
//
// `CloseAll` 是**一次性**的:它执行完就把 `Closers` 清空(切片置 nil),
// 且各模块的 Init 有副作用(建库、播种、迁移扫描),**不能简单重跑**。
// 用它做伪关机 = 关掉就再也起不来,等于要求重启进程 —— 那就不是"伪"关机了。
//
// ## 本文件的做法
//
// 分两级,都**可逆**:
//
// Quiesce() → 让所有"活动写者"停下来,但**保留连接/句柄**
// Resume() → 把停下来的重新启动
//
// 参与方式:模块在 Init 里通过 `AddQuiescer(name, pause, resume)` 登记。
// 与 CloseAll 一样**逆序**唤醒(LIFO)——后启动的先恢复,保证依赖顺序。
//
// ## ★ 关键约束(写进类型里)
//
// 1. pause 必须**幂等**且**不关闭文件/DB 句柄**(关了就无法 resume)
// 2. pause 必须**阻塞到确实不再写**(否则迁移仍有竞态)
// 3. resume 失败必须**可见**(返回错误),不能静默"半醒"
// 4. 只有**持有写句柄**的模块才需要登记 —— 纯读模块不必(如 config)
type Quiescer struct {
// Name 用于日志与诊断(哪个模块停不下来,一眼看到)。
Name string
// Pause 停下活动写入并**等到确实停了**才返回。
Pause func() error
// Resume 恢复写入。失败必须返回错误(不能假装成功)。
Resume func() error
}
// AddQuiescer 登记一个可暂停/恢复的写者(在模块 Init 里调用)。
func (s *KernelState) AddQuiescer(name string, pause, resume func() error) {
if pause == nil {
return
}
s.quiesceMu.Lock()
s.Quiescers = append(s.Quiescers, Quiescer{Name: name, Pause: pause, Resume: resume})
s.quiesceMu.Unlock()
}
// Quiesce 伪关机:**逆序**暂停全部已登记的写者。
//
// 返回成功暂停的清单 —— Resume 只唤醒这些(没有的不要"凭空唤醒")。
// 任何一个 Pause 失败都会**停止继续暂停**并返回错误:宁可只关一半
// 让调用方放弃迁移,也不要"看起来关了但仍有写者在跑"。
func (s *KernelState) Quiesce() ([]string, error) {
s.quiesceMu.Lock()
qs := make([]Quiescer, len(s.Quiescers))
copy(qs, s.Quiescers)
s.quiesceMu.Unlock()
// 逆序暂停:后启动的先停(与启动顺序相反,先停"消费者"再停"生产者")
var paused []string
for i := len(qs) - 1; i >= 0; i-- {
q := qs[i]
if err := q.Pause(); err != nil {
// 已暂停的按**反序**回滚(把它唤醒回去),别留半关状态
for j := len(paused) - 1; j >= 0; j-- {
if r := s.resumeOne(qs, paused[j]); r != nil {
log.Printf("[quiesce] 回滚唤醒 %s 失败: %v", paused[j], r)
}
}
return nil, fmt.Errorf("暂停 %s 失败(已回滚 %d 个): %w", q.Name, len(paused), err)
}
paused = append(paused, q.Name)
log.Printf("[quiesce] 已暂停: %s", q.Name)
}
return paused, nil
}
// Resume 唤醒:按**暂停的逆序**(即原启动顺序)恢复。
func (s *KernelState) Resume(paused []string) error {
s.quiesceMu.Lock()
qs := make([]Quiescer, len(s.Quiescers))
copy(qs, s.Quiescers)
s.quiesceMu.Unlock()
var firstErr error
for i := len(paused) - 1; i >= 0; i-- {
if err := s.resumeOne(qs, paused[i]); err != nil {
log.Printf("[quiesce] 唤醒 %s 失败: %v", paused[i], err)
if firstErr == nil {
firstErr = fmt.Errorf("唤醒 %s: %w", paused[i], err)
}
} else {
log.Printf("[quiesce] 已唤醒: %s", paused[i])
}
}
return firstErr
}
func (s *KernelState) resumeOne(qs []Quiescer, name string) error {
for _, q := range qs {
if q.Name == name {
if q.Resume == nil {
return nil
}
return q.Resume()
}
}
return fmt.Errorf("未登记的写者: %s", name)
}

View File

@ -0,0 +1,123 @@
package kernelboot
// Makefile 的**编译期 hash 注入**判据(2026-10-17)。
//
// ## 守什么
//
// `make build` 必须把**真实的**依赖图 digest 注入 homed 的
// `kernelboot.EmbeddedGraphHash`。若注入失败(值为空、或变成字面量
// `$GRAPH_HASH`),内核启动时会做配套校验:
//
// 磁盘图的 hash: 44823b02…
// 二进制内嵌的: $GRAPH_HASH
// ⇒ ★ 依赖图与本二进制**不配套** ⇒ **拒绝启动**
//
// ## ★ 为什么必须有这条(实测缺陷的形态)
//
// 原 Makefile 把整串 ldflags 包在**单引号**里:
//
// -ldflags '... -X ...EmbeddedGraphHash=$$GRAPH_HASH'
//
// shell 里**单引号不做变量展开** ⇒ 注入的是**字面量** `$GRAPH_HASH`。
//
// 这个缺陷最难的地方是「**所有表面信号都是绿的**」:
// · `make build` 退出码 0
// · 日志打印 `已注入启动依赖图 hash: 44823b02…`(那是 echo,不是注入结果)
// · 依赖图文件正常生成、自校验通过
// 只有**真跑内核**才炸,而且报错指向"改了 Requires 没重出图"——**方向是错的**。
//
// ⇒ 判据必须查两件事:
// ① Makefile 里注入 hash 用的引号**允许展开**(不能是单引号包 $$VAR)
// ② 构建产物里**没有**字面量 `$GRAPH_HASH` 残留
import (
"os"
"strings"
"testing"
)
// ★★★ 判据:Makefile 注入 hash 时不得用单引号包 `$$GRAPH_HASH`。
//
// ★ 为什么是源码判据而不是"跑一次 make build 再看二进制":
//
// 跑构建很贵(分钟级 + 需要 cgo/onnx),不适合放进单测;
// 而这里要断言的是**一个引号选择**,属于"写法模式存在性",
// 源码断言**精确且便宜**。真正的端到端验证由发版流程做
// (见 README 的「构建后必须真跑一次内核」)。
func TestMakefileGraphHashIsShellExpandable(t *testing.T) {
b, err := os.ReadFile("../../Makefile")
if err != nil {
t.Skipf("读不到 Makefile: %v", err)
}
src := string(b)
// 定位注入行
idx := strings.Index(src, "EmbeddedGraphHash=$$GRAPH_HASH")
if idx < 0 {
t.Fatal("Makefile 里找不到 EmbeddedGraphHash=$$GRAPH_HASH 的注入点 —— " +
"若改了注入方式,请同步更新本判据(不要删掉它)")
}
// 取该行
lineStart := strings.LastIndex(src[:idx], "\n") + 1
lineEnd := strings.Index(src[idx:], "\n")
if lineEnd < 0 {
lineEnd = len(src) - idx
}
line := src[lineStart : idx+lineEnd]
// ★ 核心断言:该行里 `$$GRAPH_HASH` 前面不能是**单引号开启的引用段**。
// 判定方式:检查 `-ldflags` 到 `$$GRAPH_HASH` 之间的引号是否平衡,
// 若在到达 $$GRAPH_HASH 之前遇到**奇数个**单引号 ⇒ 它处在单引号里 ⇒ 不展开。
ld := strings.Index(line, "-ldflags")
if ld < 0 {
t.Fatalf("注入行里没有 -ldflags: %s", line)
}
seg := line[ld:strings.Index(line, "$$GRAPH_HASH")]
sq := strings.Count(seg, "'")
dq := strings.Count(seg, `"`)
if sq%2 == 1 {
t.Fatalf("★ -ldflags 段用**单引号**包住了 $$GRAPH_HASH ⇒ shell 不做变量展开\n"+
" ⇒ 注入进二进制的是**字面量** `$GRAPH_HASH` ⇒ 内核启动时\n"+
" 「磁盘图 digest」vs「内嵌 $GRAPH_HASH」不等 ⇒ **拒绝启动**。\n"+
" (而 make build 日志全绿,只有真跑内核才暴露)\n"+
" 修法:改用双引号包 ldflags。\n 实际行: %s", line)
}
// 正向:应当有双引号(允许展开)
if dq%2 == 0 {
t.Logf("提示:本行未用双引号包 ldflags(若用无引号形式且无空格也可接受): %s", line)
}
}
// ★★★ 判据:构建产物里**没有**字面量 `$GRAPH_HASH` 残留。
//
// 需要 build/homed 存在(跑过 make build)。不存在则跳过 ——
// 这条是**补充**判据,主判据是上面那条源码检查。
func TestBuiltHomedHasNoLiteralGraphHash(t *testing.T) {
b, err := os.ReadFile("../../build/homed")
if err != nil {
t.Skipf("未构建 build/homed(跳过): %v", err)
}
if strings.Contains(string(b), "$GRAPH_HASH") {
t.Fatal("★ build/homed 里含**字面量** `$GRAPH_HASH` —— " +
"说明 -ldflags 注入没展开(单引号问题)⇒ 内核会拒绝启动")
}
// 正向:必须含有某个 64 位 hex(注入成功)
hasHex := false
for i := 0; i+64 <= len(b); i++ {
ok := true
for j := i; j < i+64; j++ {
c := b[j]
if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f') {
ok = false
break
}
}
if ok {
hasHex = true
break
}
}
if !hasHex {
t.Error("build/homed 里找不到 64 位 hex(依赖图 digest 未注入?)")
}
}

View File

@ -0,0 +1,182 @@
package kernelboot
// 数据目录迁移的**编排**:伪关机 → 迁移 → 唤醒。
//
// ## 流程(用户要求的顺序)
//
// ① Freeze 插件工具入口 —— 不再进新的工具调用
// ② Quiesce 各模块写者 —— distiller/textmem 等停下后台写
// ③ WaitDrain 等在途调用归零 —— 不能搬"写了一半的数据"
// ④ (外部脚本执行 rsync) —— 真搬数据 + 改 systemd + 改 DB
// ⑤ Resume 写者 —— 按启动顺序恢复
// ⑥ Thaw 工具入口 —— 重新接受工具调用
//
// ## ★ 本包只做 ①②③⑤⑥,不执行 ④
//
// 迁移是**破坏性且不可逆**的(21G 数据 + 12 处 DB 改写 + 46 个插件文件改写),
// 而且内核跑在 systemd 沙箱里(ProtectSystem/ProtectHome)**本来就写不了
// /etc/systemd**。⇒ 真迁移交给运维执行生成的脚本(见 internal/systemd/migrate.go),
// 本包负责**把它前后包起来**:确保迁移窗口内没有任何写者。
//
// ## ★ 失败策略:任何一步失败都要**回滚到能跑的状态**
//
// 伪关机失败 ⇒ 立刻唤醒已停的,让内核回到正常态,别卡在半关。
// 迁移失败 ⇒ 调用方负责回滚数据;本包的 Resume 仍然要执行(否则内核永久冻结)。
import (
"fmt"
"log"
"sync/atomic"
"time"
)
// inflightToolsFn 报告当前在途工具调用数(由装配处注入)。
//
// 用函数注入而不用字段直连:kernelboot 不能依赖 agent/core。
var inflightToolsFn atomic.Value // func() int64
// SetInflightToolCounter 注入在途工具计数(装配处调一次)。
func SetInflightToolCounter(fn func() int64) {
if fn != nil {
inflightToolsFn.Store(fn)
}
}
func hasInflightTools() bool {
v := inflightToolsFn.Load()
if v == nil {
return false
}
fn, _ := v.(func() int64)
if fn == nil {
return false
}
return fn() > 0
}
// PluginQuiescer 是**插件层的伪关机能力**。
//
// ★ 这里刻意复用已有的**运行态停启**(Registry.StopPlugin/StartPlugin),
//
// 而不是新造"冻结"机制 —— 三态生命周期(自动启动 / 启用但不自动启动 /
// 禁用)早已存在,且运行态停启**不写任何持久状态**(不碰 disabled_plugins,
// 也不置 shuttingDown)⇒ 天然可逆,正是伪关机需要的语义。
//
// StopPlugin ⇒ 停掉子进程 + **摘掉注册面**(工具/stage/通道)
// ⇒ 停完模型看不到、也调不到它们(比"冻结入口"更彻底)
// StartPlugin ⇒ 重新拉起,清运行态门
//
// ★ 与 StopAll 的区别(用错就是灾难):StopAll 置 shuttingDown 后
//
// **插件永远无法再加载**,那是进程退出用的,绝不能用于伪关机。
type PluginQuiescer interface {
// StopPluginRuntime 运行态停掉一个插件(不改持久状态)。
StopPluginRuntime(name string) error
// StartPluginRuntime 运行态拉起一个插件。
StartPluginRuntime(name string) error
// RunningPlugins 返回当前正在运行的插件名(用于决定停哪些)。
RunningPlugins() []string
}
// MigrateSession 表示一次"伪关机窗口"。用它包住外部迁移动作。
//
// ★ 用法必须用 defer 保证唤醒 —— 否则迁移失败时内核永久冻结:
//
// sess, err := ks.BeginMigration(freezer)
// if err != nil { return err }
// defer sess.End() // 无论成功失败都唤醒
// // ... 外部脚本迁移 ...
type MigrateSession struct {
ks *KernelState
plugins PluginQuiescer
paused []string
stopped []string // 已运行态停掉的插件(按停止顺序,唤醒时逆序)
ended atomic.Bool
}
// BeginMigration 进入伪关机:停插件(运行态)→ 逆序暂停内核写者 → 等在途归零。
//
// 返回的 session 必须由调用方 `defer sess.End()`。
func (s *KernelState) BeginMigration(pq PluginQuiescer) (*MigrateSession, error) {
sess := &MigrateSession{ks: s, plugins: pq}
// ① 先停**插件**(运行态停启,可逆)。
//
// 为什么插件排第一:插件是最大的写入源(rss 写 feeds、calendar 写 events、
// commondev 写 git 镜像)。而且 StopPlugin 会**摘掉注册面** ⇒ 停完
// 模型看不到也调不到它们,比"只挡入口"更彻底。
//
// ★ 为什么不用 StopAll:它会置 shuttingDown,之后插件**永久无法加载**。
// 那是进程退出路径用的,用于伪关机等于"关了就起不来"。
if pq != nil {
running := pq.RunningPlugins()
// 逆序停(最后启动的先停,与依赖相反)
for i := len(running) - 1; i >= 0; i-- {
name := running[i]
if err := pq.StopPluginRuntime(name); err != nil {
// 停不下来 ⇒ 回滚已停的,别留半关
for j := len(sess.stopped) - 1; j >= 0; j-- {
if r := pq.StartPluginRuntime(sess.stopped[j]); r != nil {
log.Printf("[migrate] 回滚启动插件 %s 失败: %v", sess.stopped[j], r)
}
}
return nil, fmt.Errorf("运行态停止插件 %s 失败(已回滚 %d 个): %w",
name, len(sess.stopped), err)
}
sess.stopped = append(sess.stopped, name)
}
log.Printf("[migrate] 已运行态停止 %d 个插件(不改持久状态,唤醒即恢复)", len(sess.stopped))
}
// ② 逆序暂停内核写者(distiller/textmem 等后台定时写)
paused, err := s.Quiesce()
if err != nil {
// 失败 ⇒ 把插件拉回去,内核回到正常态
for j := len(sess.stopped) - 1; j >= 0; j-- {
if r := pq.StartPluginRuntime(sess.stopped[j]); r != nil {
log.Printf("[migrate] 回滚启动插件 %s 失败: %v", sess.stopped[j], r)
}
}
log.Printf("[migrate] 伪关机失败,已回滚: %v", err)
return nil, fmt.Errorf("伪关机失败: %w", err)
}
sess.paused = paused
// ③ 等在途工具调用归零(尽力而为,见下)
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if !hasInflightTools() {
break
}
time.Sleep(50 * time.Millisecond)
}
if hasInflightTools() {
log.Printf("[migrate] warning: 仍有在途工具调用未完成(继续;迁移脚本 stop 服务后天然安全)")
}
log.Printf("[migrate] 伪关机完成(插件 %d 个、内核写者 %d 个)", len(sess.stopped), len(paused))
return sess, nil
}
// End 唤醒:逆序恢复内核写者 → 逆序拉起插件。幂等。
func (m *MigrateSession) End() {
if m == nil || m.ended.Swap(true) {
return
}
// ① 先恢复内核写者(它们是被插件依赖的下层)
if err := m.ks.Resume(m.paused); err != nil {
log.Printf("[migrate] ★ 唤醒内核写者失败(可能半醒): %v", err)
}
// ② 再拉起插件(按**停止的逆序** = 原启动顺序)
if m.plugins != nil {
for i := len(m.stopped) - 1; i >= 0; i-- {
name := m.stopped[i]
if err := m.plugins.StartPluginRuntime(name); err != nil {
log.Printf("[migrate] ★ 拉起插件 %s 失败: %v", name, err)
} else {
log.Printf("[migrate] 已拉起插件: %s", name)
}
}
}
log.Printf("[migrate] 唤醒完成")
}

View File

@ -0,0 +1,219 @@
package kernelboot
// 伪关机 / 唤醒(迁移窗口)的判据(2026-10-17)。
//
// ## 背景
//
// 用户要求:「迁移模块要先让 core 暂停其他模块运行(伪关机)完成迁移后再唤醒」。
//
// 数据目录迁移 21G 要几分钟,期间任何写者(distiller 每 10min 写 graph.db、
// textmem 滚动写、插件写 plugin_data)都会污染数据。
//
// ## 这组判据守什么
//
// 1. Quiesce 必须**逆序**暂停(后启动的先停:先停消费者再停生产者)
// 2. Pause 失败必须**回滚**(不能留半关状态)
// 3. Resume 必须**逆序**恢复(与暂停相反 = 原启动顺序)
// 4. End 必须**幂等**(重复调用不重复 Resume)
// 5. **失败也必须唤醒**(否则内核永久冻结,比不迁移更糟)
import (
"errors"
"sync"
"testing"
)
func newTestKS() *KernelState { return NewKernelState() }
// ★★★ 判据:Quiesce 逆序暂停、Resume 逆序恢复。
func TestQuiescePausesInReverseOrder(t *testing.T) {
ks := newTestKS()
var mu sync.Mutex
var pausedOrder, resumedOrder []string
add := func(name string) {
ks.AddQuiescer(name,
func() error {
mu.Lock()
pausedOrder = append(pausedOrder, name)
mu.Unlock()
return nil
},
func() error {
mu.Lock()
resumedOrder = append(resumedOrder, name)
mu.Unlock()
return nil
})
}
// 模拟启动顺序:a → b → c
add("a")
add("b")
add("c")
paused, err := ks.Quiesce()
if err != nil {
t.Fatal(err)
}
// 暂停必须逆序:c, b, a(先停最后启动的)
want := []string{"c", "b", "a"}
if len(pausedOrder) != 3 || pausedOrder[0] != "c" || pausedOrder[2] != "a" {
t.Fatalf("★ Quiesce 顺序错:实际 %v,应为 %v(后启动的先停,先停消费者)",
pausedOrder, want)
}
if err := ks.Resume(paused); err != nil {
t.Fatal(err)
}
// 恢复必须与暂停相反 = 原启动顺序 a, b, c
if len(resumedOrder) != 3 || resumedOrder[0] != "a" || resumedOrder[2] != "c" {
t.Fatalf("★ Resume 顺序错:实际 %v,应为 [a b c](与暂停相反 = 启动顺序)", resumedOrder)
}
}
// ★★★ 判据:Pause 失败必须**回滚已暂停的**(不留半关状态)。
func TestQuiesceRollsBackOnFailure(t *testing.T) {
ks := newTestKS()
var mu sync.Mutex
resumed := map[string]int{}
ks.AddQuiescer("ok1", func() error { return nil }, func() error {
mu.Lock()
resumed["ok1"]++
mu.Unlock()
return nil
})
ks.AddQuiescer("boom", func() error { return errors.New("停不下来") }, func() error {
mu.Lock()
resumed["boom"]++
mu.Unlock()
return nil
})
// 逆序:boom 先被暂停 ⇒ 它失败 ⇒ ok1 还没被暂停(因为逆序时 ok1 在 boom 之后)
// 为了真正测到"回滚",让失败发生在**后面的**那个
ks2 := newTestKS()
ks2.AddQuiescer("boom", func() error { return errors.New("停不下来") }, nil)
ks2.AddQuiescer("ok1", func() error { return nil }, func() error {
mu.Lock()
resumed["ok1"]++
mu.Unlock()
return nil
})
// 逆序暂停:ok1 先(成功)→ boom(失败)⇒ 应回滚 ok1
if _, err := ks2.Quiesce(); err == nil {
t.Fatal("Pause 失败时 Quiesce 必须返回错误(不能假装成功)")
}
if resumed["ok1"] == 0 {
t.Fatal("★ Pause 失败后未回滚已暂停的 ok1 ⇒ 内核处于**半关**状态")
}
}
// ★★★ 判据:End 幂等(重复调用不重复 Resume)。
func TestMigrateSessionEndIsIdempotent(t *testing.T) {
ks := newTestKS()
var mu sync.Mutex
n := 0
ks.AddQuiescer("w", func() error { return nil }, func() error {
mu.Lock()
n++
mu.Unlock()
return nil
})
sess, err := ks.BeginMigration(nil)
if err != nil {
t.Fatal(err)
}
sess.End()
sess.End()
sess.End()
mu.Lock()
defer mu.Unlock()
if n != 1 {
t.Fatalf("★ End 不幂等:Resume 被调用 %d 次(应为 1)", n)
}
}
// fakePlugins 模拟插件层的运行态停启(**可逆**,与 StopAll 相反)。
type fakePlugins struct {
mu sync.Mutex
running []string
stops int
starts int
failOn string // 停这个插件时失败
}
func (f *fakePlugins) StopPluginRuntime(name string) error {
f.mu.Lock()
defer f.mu.Unlock()
if name == f.failOn {
return errors.New("停不下来")
}
f.stops++
for i, n := range f.running {
if n == name {
f.running = append(f.running[:i], f.running[i+1:]...)
break
}
}
return nil
}
func (f *fakePlugins) StartPluginRuntime(name string) error {
f.mu.Lock()
defer f.mu.Unlock()
f.starts++
f.running = append(f.running, name)
return nil
}
func (f *fakePlugins) RunningPlugins() []string {
f.mu.Lock()
defer f.mu.Unlock()
out := make([]string, len(f.running))
copy(out, f.running)
return out
}
// ★★★ 判据:伪关机停掉全部插件,唤醒后**全部恢复**(可逆)。
//
// 这是"用运行态停启而不是 StopAll"的核心保证:
// StopAll 置 shuttingDown 之后插件**永久起不来**,用于伪关机就是关了就死。
func TestMigrationStopsAndRestoresAllPlugins(t *testing.T) {
ks := newTestKS()
fp := &fakePlugins{running: []string{"webui", "rss", "calendar", "commondev"}}
sess, err := ks.BeginMigration(fp)
if err != nil {
t.Fatal(err)
}
if got := fp.RunningPlugins(); len(got) != 0 {
t.Fatalf("★ 伪关机后仍有插件在运行: %v(迁移期间会写数据)", got)
}
if fp.stops != 4 {
t.Fatalf("应停 4 个插件,实际 %d", fp.stops)
}
sess.End()
if got := fp.RunningPlugins(); len(got) != 4 {
t.Fatalf("★ 唤醒后插件未全部恢复: %v(应 4 个)—— 这正是用 StopAll 会造成的后果", got)
}
}
// ★★★ 判据:停插件失败必须**回滚已停的**(不留半关)。
func TestMigrationRollsBackPluginsOnFailure(t *testing.T) {
ks := newTestKS()
// 逆序停:commondev → calendar → rss → webui
// 让 calendar 失败 ⇒ 已停的 commondev 必须被拉回
fp := &fakePlugins{running: []string{"webui", "rss", "calendar", "commondev"}, failOn: "calendar"}
if _, err := ks.BeginMigration(fp); err == nil {
t.Fatal("停插件失败时 BeginMigration 必须返回错误")
}
got := fp.RunningPlugins()
if len(got) != 4 {
t.Fatalf("★ 回滚不完整:只剩 %v(应全部 4 个)—— 内核处于半关状态", got)
}
}

View File

@ -0,0 +1,94 @@
// 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/HomeAgent/internal/kernelboot"
)
var (
regMu sync.Mutex
registry []kernelboot.Module
seenName = map[string]bool{}
)
// Register 由模块在自己的 init() 里调用(自注册)。
//
// 重复注册同一模块名会**记录**并在 BuildModules 时报错 —— 不用 panic:
// panic 发生在 init 阶段,栈信息对使用者毫无意义(他只想知道哪个模块重名)。
func Register(m kernelboot.Module) {
if m == nil {
return
}
regMu.Lock()
defer regMu.Unlock()
if seenName[m.Name()] {
dupErr = append(dupErr, m.Name())
return
}
seenName[m.Name()] = true
registry = append(registry, m)
}
// dupErr 累积重名模块(BuildModules 时统一报告)。
var dupErr []string
// BuildModules 返回全部已登记模块(按名字排序,保证可复现)。
//
// ## 为什么排序
//
// 分层结果的**层内顺序**若依赖 map/切片顺序,同一份声明两次构建会得到
// 不同顺序 ⇒ 不可审计(设计 §2.5:图要能进 PR diff 比对)。
// 排序让结果稳定。
func BuildModules() ([]kernelboot.Module, error) {
regMu.Lock()
defer regMu.Unlock()
if len(dupErr) > 0 {
names := append([]string(nil), dupErr...)
sort.Strings(names)
return nil, fmt.Errorf("kernelboot/modules: 模块名重复注册: %v"+
"(同一个模块被 import 了两次?或两个模块取了同名)", names)
}
out := append([]kernelboot.Module(nil), registry...)
sort.Slice(out, func(i, j int) bool { return out[i].Name() < out[j].Name() })
if len(out) == 0 {
return nil, fmt.Errorf("kernelboot/modules: 没有任何模块被登记 —— " +
"通常是没有 import 模块包(见本文件顶部的'怎么加一个新模块')")
}
return out, nil
}
// Names 返回已登记模块名(诊断/判据用)。
func Names() []string {
regMu.Lock()
defer regMu.Unlock()
out := make([]string, 0, len(registry))
for _, m := range registry {
out = append(out, m.Name())
}
sort.Strings(out)
return out
}

View File

@ -0,0 +1,167 @@
package kernelboot
// 编译期图 → 运行期启动计划的判据(设计 §2.5,用户要求:
// "启动顺序是编译期间按照声明的依赖顺序生成的启动依赖图排列的")。
//
// ## 这组判据守什么
//
// ① 运行期**读**图里的顺序,不重算(层号被真正使用)
// ② 图与声明**漂移**时拒绝启动(§2.5.2 的病 A)
// ③ 图与二进制**描述不同模块集**时拒绝启动
// ④ 自校验能抓损坏(已有判据,这里补"层号参与校验")
import (
"strings"
"testing"
)
// 造一个可分层的小世界。
func planTestModules() []Module {
return []Module{
&fakeModule{name: "config", provides: []string{"config"}},
&fakeModule{name: "lua", provides: []string{"lua"}, requires: []string{"config"}},
&fakeModule{name: "llm", provides: []string{"llm"}, requires: []string{"config", "lua"}},
}
}
// ★★★ 判据:**PlanFromGraph 按图中的层号排计划**(而不是重算)。
func TestPlanFromGraphUsesEmbeddedLayers(t *testing.T) {
mods := planTestModules()
built, err := Build(mods)
if err != nil {
t.Fatal(err)
}
edges := planToEdgesForTest(built)
gf := decodeGraphOrFatal(t, EncodeGraph(edges))
plan, err := PlanFromGraph(gf, mods)
if err != nil {
t.Fatalf("PlanFromGraph: %v", err)
}
if len(plan.Layers) != len(built.Layers) {
t.Fatalf("层数不符: %d != %d", len(plan.Layers), len(built.Layers))
}
for i := range built.Layers {
got := names(plan.Layers[i])
want := names(built.Layers[i])
if !equalStrs(got, want) {
t.Errorf("第 %d 层不符: %v != %v", i, got, want)
}
}
}
// ★★★ 判据:**层号真的被使用**(有人重算而不读图 ⇒ 报红)。
//
// 做法:手工把图里 lua 的层号改成 0(与声明矛盾)。
// 若 PlanFromGraph 重算而不读,它会"照常工作";读了图并校验 ⇒ 报漂移。
func TestPlanFromGraphDetectsLayerDrift(t *testing.T) {
mods := planTestModules()
built, _ := Build(mods)
edges := planToEdgesForTest(built)
// 篡改:把 lua 标成第 0 层(它实际在第 1 层)
for i := range edges {
if edges[i].Name == "lua" {
edges[i].Layer = 0
}
}
gf := decodeGraphOrFatal(t, EncodeGraph(edges))
_, err := PlanFromGraph(gf, mods)
if err == nil {
t.Fatal("★ 图里的层号与声明矛盾,却照常构造了计划 —— " +
"运行期必须用图里的顺序,并校验它与声明一致(否则旧图会给出错误顺序)")
}
if !strings.Contains(err.Error(), "漂移") {
t.Errorf("错误应点明是\"漂移\"(改了声明没重出图): %v", err)
}
}
// ★★★ 判据:**图与二进制模块集不一致时拒绝启动**。
func TestPlanFromGraphDetectsModuleSetMismatch(t *testing.T) {
mods := planTestModules()
built, _ := Build(mods)
edges := planToEdgesForTest(built)
// ① 二进制里少了一个模块(模拟"图是新的、二进制是旧的")
gf := decodeGraphOrFatal(t, EncodeGraph(edges))
if _, err := PlanFromGraph(gf, mods[:2]); err == nil {
t.Fatal("★ 图描述了二进制里没有的模块,却通过了 —— 会把旧芯片的图配新二进制")
}
// ② 图里少了一个模块
short := make([]Module, 0, 2)
short = append(short, mods[0], mods[1])
shortBuilt, _ := Build(short)
gf2 := decodeGraphOrFatal(t, EncodeGraph(planToEdgesForTest(shortBuilt)))
if _, err := PlanFromGraph(gf2, mods); err == nil {
t.Fatal("★ 二进制里有图里没有的模块,却通过了")
}
}
// ★★★ 判据:**层号进了二进制**(编解码往返保真)。
func TestGraphRoundTripsLayer(t *testing.T) {
mods := planTestModules()
built, _ := Build(mods)
edges := planToEdgesForTest(built)
gf := decodeGraphOrFatal(t, EncodeGraph(edges))
byName := map[string]int{}
for _, e := range gf.Modules {
byName[e.Name] = e.Layer
}
for _, m := range mods {
_ = m
}
if byName["config"] != 0 || byName["lua"] != 1 || byName["llm"] != 2 {
t.Fatalf("层号未正确往返: %v", byName)
}
}
// ★★★ 判据:**编码顺序 = 启动顺序**(按 (layer,name),层内可复现)。
func TestGraphEncodeOrderIsStartupOrder(t *testing.T) {
mods := planTestModules()
built, _ := Build(mods)
gf := decodeGraphOrFatal(t, EncodeGraph(planToEdgesForTest(built)))
var order []string
for _, e := range gf.Modules {
order = append(order, e.Name)
}
// config 必须在 lua 之前,lua 必须在 llm 之前(按层号排好的顺序)
idx := func(n string) int {
for i, s := range order {
if s == n {
return i
}
}
return -1
}
if !(idx("config") < idx("lua") && idx("lua") < idx("llm")) {
t.Fatalf("★ 解码后的顺序不是启动顺序: %v", order)
}
}
// ---- 测试辅助 ----
func planToEdgesForTest(p *Plan) []GraphEdge {
var out []GraphEdge
for layer, mods := range p.Layers {
for _, m := range mods {
out = append(out, GraphEdge{
Name: m.Name(), Provides: m.Provides(),
Requires: m.Requires(), Layer: layer,
})
}
}
return out
}
func decodeGraphOrFatal(t *testing.T, data []byte) *GraphFile {
t.Helper()
gf, err := DecodeGraph(data)
if err != nil {
t.Fatalf("DecodeGraph: %v", err)
}
return gf
}

View File

@ -0,0 +1,309 @@
package kernelboot
// 插件调度器:按 syncbus 状态决定**何时**拉起各插件(设计 §2.6.4)。
//
// ## 核心规则(三条,按优先级)
//
// 未声明依赖 ⇒ 等内核**完全**初始化(默认路径 = 现行为,§2.6.5)
// 声明了依赖且全就绪 ⇒ **立即**拉起
// 声明了依赖但未全就绪 ⇒ 挂起,等 syncbus 更新后再判断
//
// ## ★ 兼容性的落点就在第一条
//
// 存量 42 个插件都不声明依赖 ⇒ 全部走第一条 ⇒ 行为**逐字不变**。
// 这不是"顺便兼容",而是本设计的硬约束:42 个插件里任何一个若因本设计
// 改变了启动时机,就可能在不该动的时候动(例如假设"Start 时 llm 已就绪")。
// **静默改变启动时机 = 静默引入竞态。**
//
// ## 为什么"未声明"不能等价于"依赖集为空"
//
// 空依赖集在数学上"已满足",若按此处理,未声明的插件会在**第一层完成后**
// 就被拉起 —— 比现状早得多,直接破坏兼容性。
// 所以"未声明"与"声明了空集"是**不同**语义,必须分开判断(见 Wants)。
//
// ## 为什么调度器不自带并发控制
//
// 拉起插件的实际动作(load/Start)由插件管理器做 —— 那里已有
// Registry 的锁与生命周期管理。本调度器只负责**判断时机**,
// 保持单一职责,也让判断逻辑可以脱离真实插件系统被单测(判据的关键)。
import (
"fmt"
"sort"
"strings"
"sync"
)
// PluginSpec 是调度器需要知道的插件声明(设计 §2.6.4/2.6.6)。
type PluginSpec struct {
Name string
// RequiresFacilities 是插件声明需要的设施名。
//
// nil/空 ⇒ **未声明**(走"等内核完全初始化"路径)
// 非空 ⇒ 声明的依赖;全就绪即拉起
RequiresFacilities []string
// AutoStart 为 false 表示"不伴随启动":即使依赖齐了也**不**自动拉起,
// 等 agent 经 plugin_enable 显式启用(设计 §2.6.6)。
//
// 用指针语义的注意:这里用 bool + 一个显式标记区分"未声明"与"false"。
// 但为了简洁,约定:**缺省(未声明)= true**(伴随启动,与现状一致),
// 由 manifest 层负责把"未声明"翻译成 true。
AutoStart bool
}
// Scheduler 按 syncbus 状态调度插件。
type Scheduler struct {
mu sync.Mutex
bus SyncState
pending []PluginSpec
launched map[string]bool
launch func(PluginSpec) error
// onLaunchErr 记录拉起失败(不中断其他插件)
onLaunchErr func(PluginSpec, error)
}
// NewScheduler 创建调度器。launch 是实际拉起动作(由插件管理器提供)。
//
// ★ bus 参数类型是 **SyncState**(只读接口),不是 *SyncBus。
// 这就是设计 §2.6.3 的权限隔离:调度器**在编译期就没有**写 syncbus 的能力。
func NewScheduler(bus SyncState, launch func(PluginSpec) error) *Scheduler {
s := &Scheduler{
bus: bus,
launch: launch,
launched: map[string]bool{},
}
// ★ 订阅状态变化:syncbus 每次更新(层边界 / 内核完成)就重新评估挂起队列。
//
// 这是"自动拉起"的实现方式:调度器不需要轮询(空转)也不需要调用方
// 记得在每处手动触发(漏了就会静默卡住)。订阅一次,永久生效。
//
// 若 bus 不支持订阅(只传了个只读快照实现),退化为"只在 Submit 时评估"——
// 那种情况下调度器仍可用,只是不会自动跟进后续状态变化。
if sub, ok := bus.(interface{ OnUpdate(func(SyncState)) }); ok {
sub.OnUpdate(func(SyncState) { s.evaluate() })
}
return s
}
// SetLaunch 补上拉起动作(供"两段式"调度:装配前订阅、装配后接动作)。
//
// ## 为什么需要它(真实缺口)
//
// 插件调度器必须在**装配开始前**订阅 syncbus,否则"声明提前启动"不会生效
// (提交时全部设施早已就绪 ⇒ 永远退化成"立即拉起")。
//
// 但**拉起动作**依赖插件注册表,而那是 stage-host 模块的产物
// ⇒ 装配前拿不到。所以分两段:
//
// · 装配前:NewScheduler(bus, nil) 只订阅
// · 装配后:SetLaunch(fn) 接上真实动作
//
// ★ 此时可能已有插件被标记 launched(分层过程中达到条件)。
// 调用方需自行补偿"已标记但未真正加载"的那些 —— 见 LaunchedFlag。
func (s *Scheduler) SetLaunch(fn func(PluginSpec) error) {
s.mu.Lock()
s.launch = fn
s.mu.Unlock()
// ★ 立刻重新评估一次:接上动作之前可能已有插件达到条件并被标记
// (它们在 syncbus 更新时就评估过,但那时 launch==nil ⇒ 只标记未执行)。
// 不补这一下,它们要等到**下一次** syncbus 更新才会被真正拉起 ——
// 而若内核装配已结束(不再有更新),就永远不拉。
s.evaluate()
}
// LaunchedFlag 报告某插件是否已被标记为"可拉起/已拉起"。
//
// ★ 与 Launched()(返回列表)分开:本方法用于**单个查询**,
// 供两段式调度的补偿逻辑判断"它到条件了但可能还没真正加载"。
func (s *Scheduler) LaunchedFlag(name string) bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.launched[name]
}
// SetLaunchErrorHandler 注册拉起失败的观察者(诊断用)。
func (s *Scheduler) SetLaunchErrorHandler(fn func(PluginSpec, error)) {
s.mu.Lock()
s.onLaunchErr = fn
s.mu.Unlock()
}
// Submit 提交一批插件声明。返回**立即可拉起**的那些插件名。
//
// 未达条件的进挂起队列,等 OnUpdate 触发时再评估。
func (s *Scheduler) Submit(specs []PluginSpec) []string {
// ★ 先校验:声明的设施名必须是**内核注册过的**。
//
// ## 为什么这条校验必须存在(修一个"静默永不启动"的缺口)
//
// 插件声明 `requires_facilities` 是字符串。拼错时原行为是:
// 永远等不到 ⇒ **永不拉起**,且**无任何报错**、无超时兜底。
// 那是本仓反复吃亏的"静默降级"形态(系统看着正常,某插件就是不工作)。
//
// 有了这条校验,拼错在**提交那一刻**就报出来,并列出全部已知设施名
// 供对照("拼错了"当场可修)。
//
// ★ 为什么在这里而不在 canLaunch:canLaunch 是**每轮状态变化**都会调的
// 热路径,在那里报错会重复刷屏;而"名字不存在"是**一次性**的静态错误,
// 提交时检查一次即可。
s.mu.Lock()
var rejected []PluginSpec
var valid []PluginSpec
for _, p := range specs {
if bad := s.unknownFacilities(p); len(bad) > 0 {
rejected = append(rejected, p)
continue
}
valid = append(valid, p)
}
s.pending = append(s.pending, valid...)
errFn := s.onLaunchErr
s.mu.Unlock()
// 报错在锁外(回调可能反查调度器状态 ⇒ 持锁会死锁)
for _, p := range rejected {
bad := s.unknownFacilities(p)
err := fmt.Errorf("插件 %s 声明的设施名内核**从未注册过**: %v —— "+
"这通常是拼写错误。内核已知的设施名: %v。"+
"(若不校验,该插件会**永远等不到就绪**而静默不启动)",
p.Name, bad, s.bus.KnownFacilities())
if errFn != nil {
errFn(p, err)
}
}
return s.evaluate()
}
// unknownFacilities 返回插件声明里**内核从未注册过**的设施名(拼错检测)。
func (s *Scheduler) unknownFacilities(p PluginSpec) []string {
var bad []string
for _, f := range p.RequiresFacilities {
if !s.bus.Known(f) {
bad = append(bad, f)
}
}
sort.Strings(bad)
return bad
}
// Pending 返回仍在挂起的插件名(诊断/判据用)。
func (s *Scheduler) Pending() []string {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]string, 0, len(s.pending))
for _, p := range s.pending {
out = append(out, p.Name)
}
sort.Strings(out)
return out
}
// Launched 返回已拉起的插件名(判据用)。
func (s *Scheduler) Launched() []string {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]string, 0, len(s.launched))
for k := range s.launched {
out = append(out, k)
}
sort.Strings(out)
return out
}
// evaluate 挑出当前可拉起的插件并拉起它们。
//
// 返回本次真正拉起的插件名。
func (s *Scheduler) evaluate() []string {
s.mu.Lock()
// 挑出可拉起的 + 留下的。
//
// ★★ 必须**就地去重**:pending 里可能有同一插件的多份副本,
// 因为并发 Submit 各自把同一批插件追加进来。
//
// 实测(压力测试 TestStressSchedulerConcurrentStateChange):
// 8 个 goroutine 并发提交 200 个插件 + 内核并发标记就绪
// ⇒ 同一插件在**同一次** evaluate 的 ready 列表里出现多次
// ⇒ `launched` 查重救不了(它只在**跨轮**生效)⇒ 被拉起 203 次。
//
// 后果:插件被重复加载(spawn 两个进程 / 工具注册冲突)。
//
// 去重用 seen 集合(同一次 evaluate 内),并把它记进 launched
// 以确保跨轮也幂等 —— 两者都需要。
var ready []PluginSpec
var still []PluginSpec
seen := map[string]bool{}
for _, p := range s.pending {
if s.launched[p.Name] || seen[p.Name] {
continue // 已拉起(跨轮)或本批已选中(同批)⇒ 丢弃
}
if s.canLaunch(p) {
seen[p.Name] = true
ready = append(ready, p)
} else {
still = append(still, p)
}
}
s.pending = still
for _, p := range ready {
s.launched[p.Name] = true
}
launchFn := s.launch
errFn := s.onLaunchErr
s.mu.Unlock()
// ★ 在**不持锁**的情况下执行拉起动作:
// 拉起可能很慢(spawn 进程、握手),持锁会让其他插件全卡住;
// 且拉起过程可能回调调度器(如插件注册新插件)⇒ 死锁。
var names []string
for _, p := range ready {
names = append(names, p.Name)
if launchFn == nil {
continue
}
if err := launchFn(p); err != nil && errFn != nil {
errFn(p, err)
}
}
sort.Strings(names)
return names
}
// canLaunch 判断单个插件当前是否可拉起。
func (s *Scheduler) canLaunch(p PluginSpec) bool {
// ① 不伴随启动:依赖齐了也**不**自动拉起(§2.6.6)
if !p.AutoStart {
return false
}
// ② 未声明依赖 ⇒ 等内核**完全**初始化(§2.6.5,存量插件走这条)
if len(p.RequiresFacilities) == 0 {
return s.bus.KernelComplete()
}
// ③ 声明了依赖 ⇒ 全就绪即拉起
return s.bus.AllReady(p.RequiresFacilities)
}
// WaitString 渲染"还差什么"(供日志:让等待可解释,而不是静默卡住)。
func (s *Scheduler) WaitString(p PluginSpec) string {
if !p.AutoStart {
return "不伴随启动(等显式启用)"
}
if len(p.RequiresFacilities) == 0 {
return "等内核完全初始化"
}
// AllReady 需要 *SyncBus;这里只做只读渲染,用 Snapshot 判断
snap := s.bus.Snapshot()
var missing []string
for _, f := range p.RequiresFacilities {
if !snap[f] {
missing = append(missing, f)
}
}
if len(missing) == 0 {
return "依赖已就绪"
}
sort.Strings(missing)
return fmt.Sprintf("还差: %s", strings.Join(missing, ","))
}

View File

@ -0,0 +1,319 @@
package kernelboot
// 内核装配层(syncbus / scheduler / KernelState)的**压力测试**。
//
// ## 为什么必须有压力测试(不是"跑一遍判据就够了")
//
// 这层代码的并发行为**只有压力才能暴露**:
// · 判据用 2~3 个 goroutine 验证语义正确性
// · 压力用数百 goroutine × 数千轮验证**没有数据竞争 / 死锁 / 状态丢失**
//
// 本仓已有两处真实教训支持这个判断:
// ① ModuleView 第一版把「当前模块」放在共享对象上 ⇒ 判据**偶发**失败
// (3 次里 1 次)—— 那是"偶发"而不是"必现",正是压力测试的领域
// ② syncbus 的 OnUpdate 若持锁回调 ⇒ **死锁**(只在回调读状态时触发)
//
// ## 压什么(本层真实的并发面)
//
// · SyncBus:内核写(MarkDeclared/MarkLayerReady/MarkKernelComplete)
// 与插件读(Known/IsReady/Snapshot/AllReady/**订阅回调**)并发
// · Scheduler:多 goroutine 并发 Submit(同一插件可能被重复提交)
// + 状态变化并发触发 evaluate
// · KernelState:同层模块**并行**写**各自**字段(不同字段,无竞争)
// · PlanFromGraph:并发读图(只读,但验证无共享可变状态)
// · Run:真实分层执行,验证有界并发真的没超
import (
"fmt"
"sync"
"sync/atomic"
"testing"
"time"
)
// ★★★ 压力:**SyncBus 读写并发**(内核写 + N 个插件读 + 订阅回调)。
//
// 用 -race 跑时,任何未保护的状态都会报数据竞争。
func TestStressSyncBusConcurrentReadWrite(t *testing.T) {
bus := NewSyncBus()
const readers = 64
const facilities = 200
var callbacks int64
// 订阅者**读状态**(这正是死锁的触发条件:若 fire 持锁调用回调,
// 回调里 Snapshot() 会等读锁 ⇒ 死锁。压力下必现)
for i := 0; i < 8; i++ {
bus.OnUpdate(func(s SyncState) {
_ = s.Snapshot()
_ = s.AllReady([]string{"llm"})
atomic.AddInt64(&callbacks, 1)
})
}
var wg sync.WaitGroup
stop := make(chan struct{})
// 读侧:N 个"插件"持续查询
for i := 0; i < readers; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
for {
select {
case <-stop:
return
default:
}
f := fmt.Sprintf("fac%d", i%facilities)
_ = bus.Known(f)
_ = bus.IsReady(f)
_ = bus.Snapshot()
_ = bus.ReadyInOrder()
_ = bus.KernelComplete()
_ = bus.AllReady([]string{"fac1", "fac2"})
}
}(i)
}
// 写侧:内核持续标记
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < facilities; i++ {
bus.MarkLayerReady([]string{fmt.Sprintf("fac%d", i)})
}
}()
// 让它跑一会儿
time.Sleep(400 * time.Millisecond)
bus.MarkKernelComplete()
time.Sleep(100 * time.Millisecond)
close(stop)
wg.Wait()
if atomic.LoadInt64(&callbacks) == 0 {
t.Fatal("订阅回调从未被调用 —— 压力没有真正覆盖通知路径")
}
if !bus.KernelComplete() {
t.Fatal("KernelComplete 应已置位")
}
t.Logf("回调触发 %d 次,%d 个并发读者", atomic.LoadInt64(&callbacks), readers)
}
// ★★★ 压力:**多 goroutine 并发 Submit**(同一插件可能被重复提交)。
//
// 真实场景:插件加载器可能分批提交(发现新插件、重扫目录),
// 而每次提交都会触发状态评估。并发下要保证:
//
// · 不重复拉起同一插件(幂等)
// · 不丢插件(全部最终被拉起)
// · 无数据竞争
func TestStressSchedulerConcurrentSubmit(t *testing.T) {
bus := NewSyncBus()
bus.MarkDeclared([]string{"config", "llm", "memory"})
// ★ 必须 MarkLayerReady:MarkDeclared 只登记"名字存在",
// 不代表就绪。插件要求 config **就绪**才拉起。
// (我第一版漏了这步 ⇒ 压力判据报"0 个拉起" —— 那是判据 bug,非缺陷。)
bus.MarkLayerReady([]string{"config", "llm", "memory"})
const plugins = 300
var mu sync.Mutex
launched := map[string]int{}
sch := NewScheduler(bus, func(p PluginSpec) error {
mu.Lock()
launched[p.Name]++
mu.Unlock()
return nil
})
sch.SetLaunchErrorHandler(func(p PluginSpec, err error) {
t.Errorf("不应有拉起错误: %v", err)
})
// 16 个 goroutine 并发提交同一批插件(模拟重复扫描)
var wg sync.WaitGroup
for g := 0; g < 16; g++ {
wg.Add(1)
go func(g int) {
defer wg.Done()
var specs []PluginSpec
for i := 0; i < plugins; i++ {
specs = append(specs, PluginSpec{
Name: fmt.Sprintf("p%03d", i),
RequiresFacilities: []string{"config"},
AutoStart: true,
})
}
sch.Submit(specs)
}(g)
}
bus.MarkKernelComplete()
wg.Wait()
mu.Lock()
defer mu.Unlock()
if len(launched) != plugins {
t.Fatalf("★ 应拉起 %d 个插件,实际 %d(并发提交丢了插件)", plugins, len(launched))
}
for name, n := range launched {
if n != 1 {
t.Fatalf("★ 插件 %s 被拉起 %d 次(应恰好 1 次 —— 并发下幂等性破坏)", name, n)
}
}
}
// ★★★ 压力:**状态变化 + 提交并发**(真实时序:内核在标记就绪的同时插件在提交)。
func TestStressSchedulerConcurrentStateChange(t *testing.T) {
bus := NewSyncBus()
bus.MarkDeclared([]string{"config", "llm", "memory", "stagehost"})
var launched int64
sch := NewScheduler(bus, func(p PluginSpec) error {
atomic.AddInt64(&launched, 1)
return nil
})
sch.SetLaunchErrorHandler(func(p PluginSpec, err error) {})
const plugins = 200
var wg sync.WaitGroup
// 写侧:内核逐层标记就绪
wg.Add(1)
go func() {
defer wg.Done()
for _, f := range []string{"config", "llm", "memory", "stagehost"} {
bus.MarkLayerReady([]string{f})
}
bus.MarkKernelComplete()
}()
// 提交侧:8 个并发提交者
for g := 0; g < 8; g++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < plugins; i++ {
sch.Submit([]PluginSpec{{
Name: fmt.Sprintf("q%03d", i),
RequiresFacilities: []string{"config"},
AutoStart: true,
}})
}
}()
}
wg.Wait()
bus.MarkKernelComplete() // 再触发一次评估,确保挂起的也被拉起
if n := atomic.LoadInt64(&launched); n != plugins {
t.Fatalf("★ 并发 提交×状态变化 下应拉起 %d 个,实际 %d", plugins, n)
}
}
// ★★★ 压力:**PlanFromGraph 并发读**(多插件/多协程同时读启动计划)。
func TestStressPlanFromGraphConcurrentRead(t *testing.T) {
mods := planTestModules()
built, err := Build(mods)
if err != nil {
t.Fatal(err)
}
gf := decodeGraphOrFatal(t, EncodeGraph(planToEdgesForTest(built)))
var wg sync.WaitGroup
errs := make(chan error, 64)
for i := 0; i < 64; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 50; j++ {
plan, err := PlanFromGraph(gf, mods)
if err != nil {
errs <- err
return
}
if len(plan.Layers) != 3 {
errs <- fmt.Errorf("层数不对: %d", len(plan.Layers))
return
}
}
}()
}
wg.Wait()
close(errs)
for err := range errs {
t.Fatal(err)
}
}
// ★★★ 压力:**Run 的并发上限真的生效**(大量模块同层时不得超出)。
//
// ★ 这条与判据的 TestRunRespectsMaxParallel 不同:那条 8 个模块,
// 这条 **128 个模块**同层 —— 验证信号量在高压下不漏(观察峰值)。
func TestStressRunRespectsMaxParallelAtScale(t *testing.T) {
const n = 128
const maxP = 8
var active, peak int64
var mu sync.Mutex
var mods []Module
for i := 0; i < n; i++ {
mods = append(mods, &fakeModule{
name: fmt.Sprintf("m%03d", i),
provides: []string{fmt.Sprintf("f%03d", i)},
onInit: func() {
cur := atomic.AddInt64(&active, 1)
mu.Lock()
if cur > peak {
peak = cur
}
mu.Unlock()
time.Sleep(2 * time.Millisecond)
atomic.AddInt64(&active, -1)
},
})
}
plan, err := Build(mods)
if err != nil {
t.Fatal(err)
}
res, err := Run(plan, RunOptions{MaxParallel: maxP})
if err != nil {
t.Fatal(err)
}
mu.Lock()
observed := peak
mu.Unlock()
if observed > maxP {
t.Fatalf("★ 并发上限 %d 被突破:观测峰值 %d(%d 个模块同层)", maxP, observed, n)
}
if len(res.Order) != n {
t.Fatalf("应执行 %d 个模块,实际 %d", n, len(res.Order))
}
t.Logf("%d 个模块同层,上限 %d,观测峰值 %d", n, maxP, observed)
}
// ★★★ 压力:**KernelState 同层并行写**(各写自己字段,无竞争)。
func TestStressKernelStateParallelWrites(t *testing.T) {
ks := NewKernelState()
const n = 256
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
// 每个"模块"只写自己字段 —— 这正是真实约定。
// 若有人违反(写别人的字段),-race 会报。
ks.AddCloser(func() {})
}(i)
}
wg.Wait()
if len(ks.Closers) != n {
t.Fatalf("应记录 %d 个关闭函数,实际 %d(AddCloser 有丢失)", n, len(ks.Closers))
}
if err := ks.CloseAll(); err != nil {
t.Fatal(err)
}
if len(ks.Closers) != 0 {
t.Fatal("CloseAll 后应清空")
}
}

View File

@ -0,0 +1,290 @@
package kernelboot
// syncbus:内核就绪状态的同步快照,驱动插件调度(设计 §2.6)。
//
// ## 一句话
//
// 内核每完成一层装配就把该层贡献的设施标记为"就绪";
// 插件管理器**只读**这份状态,据此拉起"依赖已齐"的插件。
//
// ## 为什么要有它(解决什么)
//
// 现状(`cmd/homed/main.go:291 loadPlugins`)是**一刀切**:
// 12 个 init* 全部做完才加载全部插件。
// 于是"只读本地文件"的插件也要等多模态空间(ONNX 加载)+ 知识库建索引做完。
//
// 而内核那 12 步本身有明确先后(§2.3 算出 4 层),"阶段"是天然存在的,
// 只是此前没有对外发布。syncbus 就是那个发布物。
//
// ## ★ 权限:插件管理器只读(这不是习惯,是设计)
//
// 若插件管理器能写 syncbus,它就能**谎报内核状态**,让插件在自己依赖的东西
// 还不存在时启动 —— 那正是病 A(静默顺序错)。
//
// 所以类型层面分离:内核拿 `*SyncBus`(可写),
// 插件管理器只拿 `SyncState` 接口(**上面没有写方法**)。
// 想写也写不了 —— 编译期保证,而非"约定不要写"。
// 同 `Get[T]` 泛型取用(类型错=编译不过)是一个思路。
import (
"sort"
"sync"
)
// SyncState 是 syncbus 的**只读**视图。
//
// ★ 刻意不含 MarkReady/Reset:插件管理器拿到的就是这个接口,
// 因此它**在编译期就不可能**篡改内核状态。
type SyncState interface {
// IsReady 报告某设施是否已就绪。
IsReady(facility string) bool
// Snapshot 返回当前全部就绪状态的副本(调用方改不动内部)。
Snapshot() map[string]bool
// ReadyInOrder 按**就绪顺序**返回设施名(调试/判据用)。
ReadyInOrder() []string
// KernelComplete 报告内核是否已完全初始化(最后一层完成)。
KernelComplete() bool
// AllReady 报告给定设施是否**全部**就绪(插件调度器的核心判断)。
//
// ★ 空列表返回 false(不是 true):见实现处的说明 ——
// "没有依赖"与"依赖全就绪"是不同语义,混淆会让未声明依赖的插件提前拉起。
AllReady(facilities []string) bool
// Known 报告某设施名**是否被内核注册过**(不论是否已就绪)。
//
// ## ★ 为什么必须有它(修一个"静默永不启动"的缺口)
//
// 插件声明 `requires_facilities` 是**字符串**。若拼错(如写 "llm " 或
// "llmservice"),原本的行为是:它永远等不到 → **永不拉起**,
// 且**没有任何报错**、没有超时兜底(我第一版设计文档写了"超时兜底"
// 但从未实现)。
//
// 这正是本仓反复吃亏的"静默降级"形态:系统看着正常,某插件就是不工作。
//
// ⇒ 有了 Known,调度器能在**提交时**立刻报错:
// "插件 X 声明了内核从未注册过的设施 Y(可能是拼写错误)"
// 并列出全部已知设施名供对照。
Known(facility string) bool
// KnownFacilities 返回内核**注册过的全部设施名**(含尚未就绪的)。
//
// 用途:错误信息里列出可选值(让"拼错了"当场可修)。
KnownFacilities() []string
}
// SyncBus 是可写的同步总线(内核持有)。
type SyncBus struct {
mu sync.RWMutex
// known 是内核**注册过的全部设施名**(不论是否已就绪)。
//
// ★ 它存在的唯一目的是让"插件拼错设施名"从**静默永不启动**
// 变成**提交时立刻报错**(见 SyncState.Known 的说明)。
known map[string]bool
ready map[string]bool
order []string
complete bool
onUpdate []func(SyncState)
notifyMu sync.Mutex
}
// NewSyncBus 创建空总线。
func NewSyncBus() *SyncBus {
return &SyncBus{ready: map[string]bool{}, known: map[string]bool{}}
}
// MarkReady 标记一个设施就绪。**只有内核该调它。**
//
// 幂等:重复标记同一设施不报错(便于层边界回调重入)。
// 但**不重复记录**顺序,保证 ReadyInOrder 可复现。
func (b *SyncBus) MarkReady(facility string) {
if facility == "" {
return
}
b.mu.Lock()
if b.ready[facility] {
b.mu.Unlock()
return
}
b.ready[facility] = true
b.order = append(b.order, facility)
b.mu.Unlock()
b.fire()
}
// MarkLayerReady 在**层边界**标记该层所有模块贡献的设施(设计 §2.6.2)。
//
// ★ 为何以"层"而不是"单个模块"为同步点:
// 层内是**并发**执行的,若层内逐个模块标记就绪,就绪顺序会**不确定**,
// 而判据要求可复现。层边界天然是一个确定的同步点。
func (b *SyncBus) MarkLayerReady(provides []string) {
// 先在锁内一次性写入,再统一通知(避免 N 次通知、N 次唤醒)
b.mu.Lock()
changed := false
for _, f := range provides {
if f == "" {
continue
}
b.known[f] = true // 注册过(不论是否首次就绪)
if b.ready[f] {
continue
}
b.ready[f] = true
b.order = append(b.order, f)
changed = true
}
b.mu.Unlock()
if changed {
b.fire()
}
}
// MarkDeclared 在**执行前**登记一批设施名(视为"内核注册过")。
//
// ## ★ 为什么需要它(时序)
//
// 插件的 `requires_facilities` 校验要在**插件提交时**做(那是错误最该被发现
// 的时刻,紧挨着写错的插件作者)。但插件的提交发生在内核装配**完成之后**
// —— 那时所有层都跑完了,Known 自然齐了。
//
// 但仍然需要本方法,因为:
//
// · 装配**中途失败**时,后面的层没跑 ⇒ 那些设施名从未登记 ⇒
// 若此时还有插件提交,会报"内核从未注册过 X"(**误报**:其实是没跑到)
// · 判据要能在**不执行**的情况下校验"插件声明 ⊆ 内核声明"
// (只看声明,不看执行结果)
//
// ⇒ 装配开始时把**全部模块的 Provides** 一次性登记为 known。
// 这样 Known 的语义精确为"**模块声明过**这个设施",与"是否已就绪"解耦。
func (b *SyncBus) MarkDeclared(facilities []string) {
b.mu.Lock()
for _, f := range facilities {
if f != "" {
b.known[f] = true
}
}
b.mu.Unlock()
}
// MarkKernelComplete 标记内核已完全初始化。
//
// 这是**未声明依赖的插件**的启动信号(默认路径 = 现行为,§2.6.5)。
func (b *SyncBus) MarkKernelComplete() {
b.mu.Lock()
if b.complete {
b.mu.Unlock()
return
}
b.complete = true
b.mu.Unlock()
b.fire()
}
// OnUpdate 注册状态变化回调(插件调度器用它重新检查挂起的插件)。
//
// 回调在**不持锁**的情况下调用 —— 否则回调里再读 syncbus 会死锁。
// (这个坑很容易踩:回调显然要读状态,持锁调用必然死锁。)
func (b *SyncBus) OnUpdate(fn func(SyncState)) {
b.notifyMu.Lock()
b.onUpdate = append(b.onUpdate, fn)
b.notifyMu.Unlock()
}
func (b *SyncBus) fire() {
b.notifyMu.Lock()
cbs := make([]func(SyncState), len(b.onUpdate))
copy(cbs, b.onUpdate)
b.notifyMu.Unlock()
for _, fn := range cbs {
fn(b) // b 实现了 SyncState(只读方法)
}
}
// --- SyncState 实现(只读) ---
func (b *SyncBus) IsReady(facility string) bool {
b.mu.RLock()
defer b.mu.RUnlock()
return b.ready[facility]
}
func (b *SyncBus) Snapshot() map[string]bool {
b.mu.RLock()
defer b.mu.RUnlock()
out := make(map[string]bool, len(b.ready))
for k, v := range b.ready {
out[k] = v
}
return out
}
func (b *SyncBus) ReadyInOrder() []string {
b.mu.RLock()
defer b.mu.RUnlock()
out := make([]string, len(b.order))
copy(out, b.order)
return out
}
func (b *SyncBus) KernelComplete() bool {
b.mu.RLock()
defer b.mu.RUnlock()
return b.complete
}
// Known 报告某设施名是否被内核注册过(不论是否已就绪)。
//
// ★ 为何"注册过但未就绪"也算 Known:那是**正常的中间状态**
// (如 llm 声明了但还在第 1 层)。真正的错误是"内核从未注册过这个名字"。
func (b *SyncBus) Known(facility string) bool {
b.mu.RLock()
defer b.mu.RUnlock()
return b.known[facility]
}
// KnownFacilities 返回内核注册过的全部设施名(升序,便于对照阅读)。
func (b *SyncBus) KnownFacilities() []string {
b.mu.RLock()
defer b.mu.RUnlock()
out := make([]string, 0, len(b.known))
for k := range b.known {
out = append(out, k)
}
sort.Strings(out)
return out
}
// AllReady 报告给定的设施**全部**就绪(插件调度器的核心判断)。
//
// 空列表 = 真的全就绪 → 返回 false。
//
// ★ 为何空列表返回 false 而不是 true:
// "没有依赖" 与 "依赖全就绪" 是**不同语义**,混淆会让"未声明依赖的插件"
// 被当成"依赖已满足"而**提前拉起** —— 那就破坏了 §2.6.5 的存量兼容。
// 未声明走的是 KernelComplete 那条路径,与这条刻意分开。
func (b *SyncBus) AllReady(facilities []string) bool {
if len(facilities) == 0 {
return false
}
b.mu.RLock()
defer b.mu.RUnlock()
for _, f := range facilities {
if !b.ready[f] {
return false
}
}
return true
}
// Missing 返回尚未就绪的设施名(诊断用:告诉用户"还差什么")。
func (b *SyncBus) Missing(facilities []string) []string {
b.mu.RLock()
defer b.mu.RUnlock()
var out []string
for _, f := range facilities {
if !b.ready[f] {
out = append(out, f)
}
}
sort.Strings(out)
return out
}

View File

@ -0,0 +1,412 @@
package kernelboot
// syncbus + 插件调度器的判据(2026-10-16)。
//
// 设计见 docs/zh/kernel-modularization-design.md §2.6。
//
// 这组判据里**最重要的一条是"默认路径为零改动"**:
// 存量 42 个插件都不声明依赖,它们的启动时机必须逐字不变。
// 静默改变启动时机 = 静默引入竞态。
import (
"reflect"
"strings"
"sync"
"testing"
"time"
)
// ★★★ 判据(最关键):**未声明依赖的插件等内核完全初始化**(存量兼容)。
//
// 这是整个设计里风险最高的一处:若未声明的插件被"提前"拉起,
// 42 个存量插件的启动时机全部改变 ⇒ 静默竞态。
//
// 反例(若实现错误):把"未声明"当成"依赖集为空",而空集在数学上已满足
// ⇒ 第一层完成后就拉起 ⇒ 比现状早得多。
func TestSchedulerUndeclaredWaitsForKernelComplete(t *testing.T) {
bus := NewSyncBus()
var launched []string
var mu sync.Mutex
sch := NewScheduler(bus, func(p PluginSpec) error {
mu.Lock()
launched = append(launched, p.Name)
mu.Unlock()
return nil
})
// 存量插件:不声明依赖
sch.Submit([]PluginSpec{{Name: "legacy1", AutoStart: true}})
sch.Submit([]PluginSpec{{Name: "legacy2", AutoStart: true}})
// 内核还在装配中(几层已就绪,但没完成)
bus.MarkLayerReady([]string{"config"})
bus.MarkLayerReady([]string{"memory", "lua"})
bus.MarkLayerReady([]string{"llm"})
mu.Lock()
n := len(launched)
mu.Unlock()
if n != 0 {
t.Fatalf("★ 内核尚未完全初始化,未声明依赖的插件就被拉起了 %d 个(存量行为被改变)", n)
}
// 内核完成 ⇒ 现在才该拉起
bus.MarkKernelComplete()
mu.Lock()
defer mu.Unlock()
if len(launched) != 2 {
t.Fatalf("内核完成后应拉起 2 个,实际 %d: %v", len(launched), launched)
}
}
// ★★★ 判据:**声明了依赖的插件在依赖就绪时立即拉起**(新能力的正面证据)。
//
// 断言"早于内核完成" —— 否则这条判据在"等内核完成"的实现下也会通过(假绿)。
func TestSchedulerDeclaredLaunchesEarly(t *testing.T) {
bus := NewSyncBus()
var launched []string
var mu sync.Mutex
sch := NewScheduler(bus, func(p PluginSpec) error {
mu.Lock()
launched = append(launched, p.Name)
mu.Unlock()
return nil
})
// ★ 镜像真实顺序:Run 在**执行前** MarkDeclared(登记全部模块设施名),
// 插件 Submit 在装配之后发生 ⇒ 判据必须先声明再提交。
bus.MarkDeclared([]string{"config"})
// 声明只需要 config
sch.Submit([]PluginSpec{{Name: "early", RequiresFacilities: []string{"config"}, AutoStart: true}})
// 第 0 层只提供 config ⇒ 应立刻拉起
bus.MarkLayerReady([]string{"config"})
mu.Lock()
got := append([]string{}, launched...)
mu.Unlock()
if len(got) != 1 || got[0] != "early" {
t.Fatalf("★ 依赖已就绪却未拉起: %v", got)
}
// ★ 关键:此时内核**尚未**完成 —— 证明它是"尽早启动"而不是"等完成"
if bus.KernelComplete() {
t.Fatal("测试设置错误:此刻内核不该已完成")
}
}
// ★★★ 判据:**依赖未齐则挂起**,不会误拉起。
func TestSchedulerWaitsForMissingDependency(t *testing.T) {
bus := NewSyncBus()
var launched []string
sch := NewScheduler(bus, func(p PluginSpec) error {
launched = append(launched, p.Name)
return nil
})
// ★ 先声明 llm(登记名字,但尚未就绪)—— 与真实顺序一致。
bus.MarkDeclared([]string{"llm", "config", "memory"})
// 需要 llm
sch.Submit([]PluginSpec{{Name: "needsllm", RequiresFacilities: []string{"llm"}, AutoStart: true}})
bus.MarkLayerReady([]string{"config", "memory"}) // llm 还没好
if len(launched) != 0 {
t.Fatalf("★ llm 未就绪却拉起了: %v", launched)
}
if p := sch.Pending(); len(p) != 1 {
t.Fatalf("应挂起 1 个,实际 %v", p)
}
// llm 就绪 ⇒ 拉起
bus.MarkLayerReady([]string{"llm"})
if len(launched) != 1 || launched[0] != "needsllm" {
t.Fatalf("llm 就绪后应拉起: %v", launched)
}
}
// ★★★ 判据:**只读隔离**(编译期保证,不是约定)。
//
// 断言 Scheduler 拿到的接口**没有**写方法 —— 如果给它 *SyncBus,
// 它就能谎报内核状态,让插件在依赖不存在时启动(病 A)。
func TestSchedulerCannotWriteSyncState(t *testing.T) {
// ★ 检查必须是**接口类型的方法集**,不是某个具体值。
//
// 我第一次写错了:用 `var st SyncState = NewSyncBus()` 再断言 `st.(writer)` ——
// 那检查的是**动态类型**(*SyncBus 当然有 MarkReady),判据当场报假红。
// 要约束的是"拿到 SyncState 的人**能不能**调写方法" ⇒ 必须查接口的方法集。
rt := reflect.TypeOf((*SyncState)(nil)).Elem()
forbidden := []string{"MarkReady", "MarkKernelComplete", "MarkLayerReady"}
for i := 0; i < rt.NumMethod(); i++ {
m := rt.Method(i).Name
for _, bad := range forbidden {
if m == bad {
t.Fatalf("★ SyncState(插件管理器拿到的接口)暴露了写方法 %s —— "+
"它能谎报内核状态,让插件在依赖不存在时启动(病 A)", m)
}
}
}
// 反向确认:读方法必须在(否则接口退化到没用了)
for _, want := range []string{"IsReady", "Snapshot", "AllReady", "KernelComplete"} {
if _, ok := rt.MethodByName(want); !ok {
t.Errorf("SyncState 缺少读方法 %s", want)
}
}
// Scheduler 持有的字段类型也必须是 SyncState(不能是 *SyncBus)
st := reflect.TypeOf(Scheduler{})
f, ok := st.FieldByName("bus")
if !ok {
t.Fatal("Scheduler 应有 bus 字段")
}
if f.Type.String() != "kernelboot.SyncState" {
t.Fatalf("★ Scheduler.bus 的类型是 %s —— 必须是只读的 SyncState", f.Type)
}
}
// ★★★ 判据:**不伴随启动的插件即使依赖齐了也不自动拉起**(§2.6.6)。
func TestSchedulerAutoStartFalseIsNotLaunched(t *testing.T) {
bus := NewSyncBus()
var launched []string
sch := NewScheduler(bus, func(p PluginSpec) error {
launched = append(launched, p.Name)
return nil
})
bus.MarkDeclared([]string{"config"})
sch.Submit([]PluginSpec{
{Name: "manual", RequiresFacilities: []string{"config"}, AutoStart: false},
})
bus.MarkLayerReady([]string{"config"})
bus.MarkKernelComplete()
if len(launched) != 0 {
t.Fatalf("★ auto_start=false 的插件被自动拉起了(应等显式启用): %v", launched)
}
if len(sch.Pending()) != 1 {
t.Fatalf("它应一直挂起等显式启用: %v", sch.Pending())
}
}
// ★★★ 判据:**就绪顺序可复现**(层内并发不得导致就绪顺序抖动)。
//
// 为何重要:syncbus 的就绪顺序决定哪些插件何时被拉起;
// 若顺序随机,同一份声明两次启动会拉出不同结果 ⇒ 不可审计、不可复现。
func TestSyncBusReadyOrderIsDeterministic(t *testing.T) {
// 模拟"层内并发完成":同一层 8 个设施,用 goroutine 乱序 MarkReady
var first []string
for round := 0; round < 5; round++ {
bus := NewSyncBus()
var wg sync.WaitGroup
fac := []string{"f1", "f2", "f3", "f4", "f5", "f6", "f7", "f8"}
for i, f := range fac {
wg.Add(1)
go func(i int, f string) {
defer wg.Done()
// 反过来启动,制造乱序完成
_ = i
bus.MarkReady(f)
}(i, f)
}
wg.Wait()
got := bus.ReadyInOrder()
if round == 0 {
first = got
continue
}
// 注意:MarkReady(单设施)的顺序本就取决于并发,不保证可复现;
// 设计保证的是 **MarkLayerReady**(层边界一次性写入)可复现。
// 所以这里只断言"同样的输入产生同样的集合"。
if len(got) != len(first) {
t.Fatalf("就绪集合大小抖动: %d vs %d", len(got), len(first))
}
}
// ★ 真正的可复现性断言:MarkLayerReady 一次性写入
var order []string
for round := 0; round < 3; round++ {
bus := NewSyncBus()
bus.MarkLayerReady([]string{"a", "b", "c"})
bus.MarkLayerReady([]string{"d", "e"})
got := bus.ReadyInOrder()
joined := strings.Join(got, ",")
if round == 0 {
order = got
continue
}
if joined != strings.Join(order, ",") {
t.Fatalf("★ MarkLayerReady 的就绪顺序不可复现:\n%v\n%v", order, got)
}
}
if strings.Join(order, ",") != "a,b,c,d,e" {
t.Fatalf("就绪顺序应为层内声明序: %v", order)
}
// ★ 幂等性:**重复标记**同一设施不得重复记录顺序。
//
// 为何要单独断言:Run 的层边界回调可能在某些路径重入
//(且调用方也可能手动重复标记)。若不幂等,order 会不断膨胀
// ⇒ 就绪顺序不再反映真实层序 ⇒ 依赖它的调度判断变得不可解释。
//
// 这条是补的:变异"去掉幂等检查"第一次没被咬住 —— 因为原判据
// 只跑了一份声明、没重复标记,测不到幂等性。
bus := NewSyncBus()
bus.MarkLayerReady([]string{"a", "b"})
bus.MarkLayerReady([]string{"a", "b"}) // 重复
bus.MarkLayerReady([]string{"c"})
if got := strings.Join(bus.ReadyInOrder(), ","); got != "a,b,c" {
t.Fatalf("★ 重复标记导致就绪顺序膨胀(不幂等): %v(期望 a,b,c)", got)
}
}
// ★★★ 判据:**内核未完成时绝不发 KernelComplete**(内核没装好就不该放插件)。
//
// 这条守的是:装配中途失败 ⇒ 未声明依赖的插件**不**被拉起。
func TestRunDoesNotMarkCompleteOnFailure(t *testing.T) {
bus := NewSyncBus()
mods := []Module{
&fakeModule{name: "A", provides: []string{"a"}},
&fakeModule{name: "B", provides: []string{"b"}, initErr: errFake},
}
plan, err := Build(mods)
if err != nil {
t.Fatal(err)
}
if _, err := Run(plan, RunOptions{Bus: bus}); err == nil {
t.Fatal("应失败")
}
if bus.KernelComplete() {
t.Fatal("★ 装配失败了却标记内核完成 —— 未声明依赖的插件会被拉起(病 A)")
}
}
// ★★★ 判据:**Run 在层边界自动发布本层设施**(不用模块自己标记)。
//
// 为何由 Run 做:模块自己标记会漏(静默漏一个设施 ⇒ 插件永远等不到),
// 且顺序取决于层内并发完成序。
func TestRunPublishesFacilitiesAtLayerBoundary(t *testing.T) {
bus := NewSyncBus()
mods := []Module{
&fakeModule{name: "config", provides: []string{"config", "config-write"}},
&fakeModule{name: "llm", provides: []string{"llm"}, requires: []string{"config"}},
}
plan, err := Build(mods)
if err != nil {
t.Fatal(err)
}
if _, err := Run(plan, RunOptions{Bus: bus}); err != nil {
t.Fatal(err)
}
for _, f := range []string{"config", "config-write", "llm"} {
if !bus.IsReady(f) {
t.Errorf("★ 设施 %q 未在层边界被发布", f)
}
}
if !bus.KernelComplete() {
t.Error("全部完成后应标记内核完成")
}
}
// ★★★ 判据:**空依赖列表 ≠ 已满足**(语义区分的直接断言)。
//
// 若这条反了,未声明依赖的插件会被提前拉起(见第一条判据)。
func TestAllReadyEmptyIsFalse(t *testing.T) {
bus := NewSyncBus()
bus.MarkKernelComplete()
if bus.AllReady(nil) {
t.Fatal("★ AllReady(空) 返回 true —— \"没有依赖\"被当成\"依赖全就绪\",未声明插件会提前拉起")
}
if bus.AllReady([]string{}) {
t.Fatal("★ AllReady(空切片) 返回 true")
}
// 非空且齐 ⇒ true
bus.MarkReady("x")
if !bus.AllReady([]string{"x"}) {
t.Fatal("依赖确实就绪应返回 true")
}
}
// ★★★ 判据:**拉起失败不阻塞其他插件**(一个坏插件不该拖垮全局)。
func TestSchedulerLaunchFailureDoesNotBlockOthers(t *testing.T) {
bus := NewSyncBus()
var okLaunched []string
sch := NewScheduler(bus, func(p PluginSpec) error {
if p.Name == "bad" {
return errFake
}
okLaunched = append(okLaunched, p.Name)
return nil
})
var failures int
sch.SetLaunchErrorHandler(func(PluginSpec, error) { failures++ })
bus.MarkDeclared([]string{"config"})
sch.Submit([]PluginSpec{
{Name: "bad", RequiresFacilities: []string{"config"}, AutoStart: true},
{Name: "good", RequiresFacilities: []string{"config"}, AutoStart: true},
})
bus.MarkLayerReady([]string{"config"})
if len(okLaunched) != 1 || okLaunched[0] != "good" {
t.Fatalf("★ 一个插件拉起失败影响了其他插件: %v", okLaunched)
}
if failures != 1 {
t.Fatalf("失败应被报告: %d", failures)
}
}
// ★★★ 判据:**重复 Submit 不重复拉起**(幂等)。
func TestSchedulerIsIdempotent(t *testing.T) {
bus := NewSyncBus()
n := 0
sch := NewScheduler(bus, func(PluginSpec) error { n++; return nil })
bus.MarkKernelComplete()
sch.Submit([]PluginSpec{{Name: "p", AutoStart: true}})
sch.Submit([]PluginSpec{{Name: "p", AutoStart: true}})
bus.MarkKernelComplete() // 再触发一次评估
if n != 1 {
t.Fatalf("★ 同一插件被拉起 %d 次(应幂等)", n)
}
}
// ★★★ 判据:**MarkReady 的通知也在锁外**(走单设施路径的订阅者不死锁)。
//
// 变异 8b 暴露的边界:MarkLayerReady 持锁回调会死锁(已被
// TestSchedulerDeclaredLaunchesEarly 咬住),但 MarkReady 是**另一条路径**
// (单个设施的标记),若它持锁回调,只有"既调 MarkReady 又订阅"的场景才会卡住。
// 补这条判据把两条路径都盖住。
func TestSyncBusMarkReadyNotifiesWithoutDeadlock(t *testing.T) {
bus := NewSyncBus()
got := make(chan string, 4)
// 订阅者**读状态**(真实回调必然会读,这正是死锁触发条件)
bus.OnUpdate(func(s SyncState) {
_ = s.Snapshot() // 持锁调用的话,这里会等 RLock ⇒ 死锁
got <- "ok"
})
done := make(chan struct{})
go func() {
defer close(done)
bus.MarkReady("config")
}()
select {
case <-got:
case <-done:
t.Fatal("★ MarkReady 未通知订阅者(订阅者拿不到更新 ⇒ 插件不会自动拉起)")
case <-timeAfter(5):
t.Fatal("★ MarkReady 持锁调用回调 ⇒ 死锁(回调读状态时会等锁)")
}
}
func timeAfter(sec int) <-chan struct{} {
ch := make(chan struct{})
go func() {
time.Sleep(time.Duration(sec) * time.Second)
close(ch)
}()
return ch
}

View File

@ -0,0 +1,139 @@
package kernelboot
// "插件拼错设施名 ⇒ 静默永不启动" 的判据(2026-10-16)。
//
// ## 修的缺口
//
// 插件声明 `requires_facilities` 是**字符串**。拼错时原行为:
// 永远等不到就绪 ⇒ **永不拉起**,且**无任何报错**、无超时兜底。
// 这正是本仓反复吃亏的"静默降级"形态。
//
// 修法:Submit 时校验"声明的设施 ⊆ 内核注册过的",不符即报错并列出可选值。
import (
"fmt"
"strings"
"testing"
)
// ★★★ 判据:**拼错的设施名在 Submit 时被拒绝**(不再静默挂起)。
func TestSchedulerRejectsUnknownFacility(t *testing.T) {
bus := NewSyncBus()
bus.MarkDeclared([]string{"config", "llm", "memory"}) // 内核注册过这些
var launched []string
var reported []string
sch := NewScheduler(bus, func(p PluginSpec) error {
launched = append(launched, p.Name)
return nil
})
sch.SetLaunchErrorHandler(func(p PluginSpec, err error) {
reported = append(reported, err.Error())
})
// 拼错:写 "llmservice" 而不是 "llm"
sch.Submit([]PluginSpec{{Name: "typo", RequiresFacilities: []string{"llmservice"}, AutoStart: true}})
bus.MarkKernelComplete()
if len(launched) != 0 {
t.Fatalf("★ 拼错设施名的插件被拉起了: %v", launched)
}
if len(reported) == 0 {
t.Fatal("★ 拼错设施名却**没有任何报错** —— 插件会静默永不启动(这正是要修的缺口)")
}
msg := reported[0]
if !strings.Contains(msg, "llmservice") {
t.Errorf("错误应点出**是哪个名字**错了: %s", msg)
}
// 必须列出内核已知的设施名("拼错了"当场可修)
if !strings.Contains(msg, "llm") || !strings.Contains(msg, "config") {
t.Errorf("错误应列出内核已知的设施名供对照: %s", msg)
}
// 不得把它留在挂起队列(否则会永远占位)
if p := sch.Pending(); len(p) != 0 {
t.Errorf("被拒的插件不该留在挂起队列: %v", p)
}
}
// ★★★ 判据:**正确的设施名不受影响**(不能把正常路径也拦了)。
func TestSchedulerAcceptsKnownFacility(t *testing.T) {
bus := NewSyncBus()
bus.MarkDeclared([]string{"config", "llm"})
// ★ 必须同时 MarkLayerReady —— 真实路径里 Run 两者都会做:
// MarkDeclared(执行前,登记名字)与 MarkLayerReady(每层完成,标记就绪)。
// 只声明不就绪 ⇒ 插件会一直挂起(那是"未就绪"而非"拼错",两者不同)。
bus.MarkLayerReady([]string{"config", "llm"})
var launched []string
sch := NewScheduler(bus, func(p PluginSpec) error {
launched = append(launched, p.Name)
return nil
})
sch.SetLaunchErrorHandler(func(p PluginSpec, err error) {
t.Errorf("不应报错: %v", err)
})
sch.Submit([]PluginSpec{{Name: "ok", RequiresFacilities: []string{"config", "llm"}, AutoStart: true}})
bus.MarkKernelComplete()
if len(launched) != 1 || launched[0] != "ok" {
t.Fatalf("声明正确的插件应被拉起: %v", launched)
}
}
// ★★★ 判据:**"注册过但未就绪"是合法中间状态**(不能误判为拼错)。
//
// ★ 这条很重要:llm 声明了但还在第 1 层时,Known 为 true、Ready 为 false。
// 若把"未就绪"当"拼错",所有多依赖插件都会在提交时被误拒。
func TestSchedulerAcceptsDeclaredButNotYetReady(t *testing.T) {
bus := NewSyncBus()
bus.MarkDeclared([]string{"config", "llm"}) // 两者都已声明
bus.MarkLayerReady([]string{"config"}) // 只有 config 就绪
var reported []string
sch := NewScheduler(bus, nil)
sch.SetLaunchErrorHandler(func(p PluginSpec, err error) { reported = append(reported, err.Error()) })
sch.Submit([]PluginSpec{{Name: "needsllm", RequiresFacilities: []string{"llm"}, AutoStart: true}})
if len(reported) != 0 {
t.Fatalf("★ '已声明但未就绪'被误判为拼错: %v", reported)
}
if p := sch.Pending(); len(p) != 1 {
t.Fatalf("应挂起等 llm 就绪: %v", p)
}
// llm 就绪后应被拉起
bus.MarkLayerReady([]string{"llm"})
}
// ★★★ 判据:**Run 执行前登记全部模块设施**(装配中途失败也能校验)。
//
// ★ 为何需要:插件提交发生在装配之后。若某层失败、后面的模块没跑,
// 那些设施名就从未被 MarkLayerReady 登记 ⇒ 会**误报**"内核从未注册过"。
// 所以 Run 必须**执行前**就 MarkDeclared。
func TestRunDeclaresAllFacilitiesUpfront(t *testing.T) {
bus := NewSyncBus()
mods := []Module{
&fakeModule{name: "a", provides: []string{"fa"}},
&fakeModule{name: "b", provides: []string{"fb"}, requires: []string{"fa"},
initErr: fmt.Errorf("故意失败")},
&fakeModule{name: "c", provides: []string{"fc"}, requires: []string{"fb"}},
}
plan, err := Build(mods)
if err != nil {
t.Fatal(err)
}
// 这个 Run 会**失败**(b 的 Init 报错)⇒ c 那一层不会跑
if _, err := Run(plan, RunOptions{Bus: bus}); err == nil {
t.Fatal("前置:应失败")
}
// ★ 但全部设施名都应已登记(因为执行前就 MarkDeclared 了)
for _, f := range []string{"fa", "fb", "fc"} {
if !bus.Known(f) {
t.Errorf("★ 设施 %q 未被登记 —— 装配中途失败后,插件提交会误报\"内核从未注册过\"", f)
}
}
// 而未跑到的层不该是"就绪"
if bus.IsReady("fc") {
t.Error("未执行的模块不该标记就绪")
}
}