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

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

View File

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

View File

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

View File

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

View File

@ -1,94 +0,0 @@
// Package modules 是内核模块的**登记表**(不含任何具体模块的实现)。
//
// ## 设计意图(用户设计)
//
// > 每个内核模块显式各自声明依赖 … 最先初始化的 core 自然不依赖任何模块,
// > 下一步初始化的依赖 core 以此类推
//
// ## 怎么加一个新模块(这是本设计的**验收标准**)
//
// ① 新建一个文件(如 modules/mymod.go)
// ② 写一个类型,实现 kernelboot.Module(Name/Requires/Provides/Init)
// ③ 在同文件的 init() 里调 modules.Register(&MyMod{})
// ④ 在 modules/imports.go 加一行 `_ "...modules"` —— **仅此而已**
//
// **不需要**编辑任何中央清单:依赖写在模块**自己**的 Requires() 里。
//
// ★ 为何要"防漏 import"的判据(见 registry_test.go):
// 忘了 import ⇒ 模块**根本不登记** ⇒ 它不参与装配 ⇒ 依赖它的模块报
// "缺依赖"(响亮),但**不依赖它的模块会静默少了那个设施**
// (如插件注册表没建 ⇒ 插件全部加载不了,而错误信息指向插件而非根因)。
// ⇒ 判据必须自动扫描目录,要求每个模块文件都被 import。
package modules
import (
"fmt"
"sort"
"sync"
"github.com/JianFeeeee/HologramAgent/internal/kernelboot"
)
var (
regMu sync.Mutex
registry []kernelboot.Module
seenName = map[string]bool{}
)
// Register 由模块在自己的 init() 里调用(自注册)。
//
// 重复注册同一模块名会**记录**并在 BuildModules 时报错 —— 不用 panic:
// panic 发生在 init 阶段,栈信息对使用者毫无意义(他只想知道哪个模块重名)。
func Register(m kernelboot.Module) {
if m == nil {
return
}
regMu.Lock()
defer regMu.Unlock()
if seenName[m.Name()] {
dupErr = append(dupErr, m.Name())
return
}
seenName[m.Name()] = true
registry = append(registry, m)
}
// dupErr 累积重名模块(BuildModules 时统一报告)。
var dupErr []string
// BuildModules 返回全部已登记模块(按名字排序,保证可复现)。
//
// ## 为什么排序
//
// 分层结果的**层内顺序**若依赖 map/切片顺序,同一份声明两次构建会得到
// 不同顺序 ⇒ 不可审计(设计 §2.5:图要能进 PR diff 比对)。
// 排序让结果稳定。
func BuildModules() ([]kernelboot.Module, error) {
regMu.Lock()
defer regMu.Unlock()
if len(dupErr) > 0 {
names := append([]string(nil), dupErr...)
sort.Strings(names)
return nil, fmt.Errorf("kernelboot/modules: 模块名重复注册: %v"+
"(同一个模块被 import 了两次?或两个模块取了同名)", names)
}
out := append([]kernelboot.Module(nil), registry...)
sort.Slice(out, func(i, j int) bool { return out[i].Name() < out[j].Name() })
if len(out) == 0 {
return nil, fmt.Errorf("kernelboot/modules: 没有任何模块被登记 —— " +
"通常是没有 import 模块包(见本文件顶部的'怎么加一个新模块')")
}
return out, nil
}
// Names 返回已登记模块名(诊断/判据用)。
func Names() []string {
regMu.Lock()
defer regMu.Unlock()
out := make([]string, 0, len(registry))
for _, m := range registry {
out = append(out, m.Name())
}
sort.Strings(out)
return out
}

View File

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

View File

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

View File

@ -0,0 +1,923 @@
package all
// 「真实登记表 → 整机启动图」的判据(2026-10-24)。
//
// ## 迁移记录(ID:13,2026-10-24)
//
// 本文件原先挂在 `internal/kernelboot/modules/registry_test.go`,那个包
// **零生产调用**(死包:Register/BuildModules 是 kernelmod 那套的**第二份
// 实现**,外部调用点 = 0),只为了安放这组判据才留着。
//
// 现迁移到**真实聚合入口** `internal/kernelmod/all`:本包本来就是
// 「把全部模块 import 进来」的那一层,判据放在这里,用的就是生产路径
// 同一条 import 链(all → 各模块 init → kernelmod.Register)。
// 死包与其第二份注册表实现已删除。
//
// ★ 迁移没有削弱任何判据的能力:下面判据 5 的真相源(扫 `..` 源码树的
// 类型大小)与宿主包无关,换宿主前后都照样咬得住「登记了但漏 import」。
// 变异亲测见本文件「## 失败面」一节里标题后注「逐条实测过」的那一处
// (不是 TestEveryRegisteredModuleIsCompiledIn 上方注「已实测咬红」的那一处)
// 第 1–3 条。★ 按**该节的区分词**找,不按行号/位置找:本文件有**两处**失败面
// 小节,且章节位置会随重构漂移。
//
// ## 这个文件补的是什么(此前完全没有的覆盖)
//
// `internal/kernelboot` 自身的判据很密(图编解码、分层、调度、迁移回滚、
// 并发压测),但**全部用假模块**(`fakeModule`)在图上验算法。
// 也就是说:分层算法被验过,而「**真实的** 17 个模块按真实登记表装配成
// 一台整机、逐层起来、再严格逆序关掉」这件事**一条用例都没有**。
//
// 这中间藏着一整类失败:算法对、单模块对,但**装配起来**不对 ——
// 漏挂一个模块(依赖图缺设施)、某层的关闭函数没登记(漏关)、
// 关闭顺序反了(先关被依赖者,后关依赖者)。
//
// ## 为什么判据必须用真实登记表,而不能造一份"期望清单"
//
// 造假模块:任何"漏挂"都测不出来(假模块之间自洽)。
// 抄一份期望模块名清单:清单在「新增模块但忘了 import」时**也跟着漏**
// ⇒ 判据照绿(本仓已有先例:TestEveryModuleDirIsImported 的初版就栽在
// 手写清单上)。
//
// ⇒ 判据 1–4 用 `kernelmod.Registered()` 作为装配输入,断言的对象是
// **装配结果**(图 + 就绪状态 + 关闭顺序 + 资源回收),而不是某份清单。
//
// ## ★★ 真相源不能是要检测对象的入口(本文件最重要的一条设计约束)
//
// 判据 5(`TestEveryRegisteredModuleIsCompiledIn`)**不能**扫
// `kernelmod.Register(...)` 调用点作为真相源,因为"模块静默消失"有两种形态:
//
// a. 四方法写全、init() 里调了 Register,只差 all/all.go 的聚合 import
// ⇒ Register 调用点仍在源码里 ⇒ 扫调用点**抓得住**
// b. 四方法写全,但 init() 根本没写(或写了又被删)
// ⇒ Register 调用点不存在 ⇒ **要检测的入口自己消失了**
// ⇒ 源码侧与登记表侧同时少一个 ⇒ 集合比对照常相等 ⇒ **假绿**
//
// b 才是"静默消失"最危险的形态:编译通过、四方法齐全、只是没人注册。
// 把「被测对象」同时当「检测入口」,入口一被破坏,判据就**失去视野**——
// 这与本仓此前几次判据假绿同病(真相源与被测对象同源)。
//
// ⇒ 判据 5 的真相源换成与 Register 无关的**接口形状**:
// 「目录 + 类型名」复合键 + 「Name/Requires/Provides/Init 四方法齐全」。
// 模块哪怕不 Register、不被 import,四方法仍写在源码里 ⇒ 一定扫得到。
//
// · 为什么必须带**目录**:本仓大量类型都叫 `Module`,只按类型名做键会撞成
// 一堆(实测撞完只剩 2 个)
// · 为什么必须**四方法集**而不是"有个 Name()":只有 Name 的类型到处都是,
// 四个方法齐才是 `kernelboot.Module` 的形状
// · 为什么取 Name() 的**字符串字面量**:非字面量会让该模块从视野消失,
// 故显式报错**而不静默跳过**(跳过 = 又一次假绿)
//
// ★ 下次谁要改这条判据,先回答:**把它的真相源破坏掉,它还会绿吗?**
// 若会绿,说明真相源选在了被测对象身上,必须换(见上面 b)。
//
// ## 失败面(**逐条实测过**,2026-10-24;不是注释声称)
//
// 记法:⇒ 后面写「谁红 + 红在哪一句」。凡实测与预期不符的,照实写实际形状。
//
// 1. 删掉**有下游依赖**的模块的 `func init() { kernelmod.Register(...) }`
// (实测对象:tracker)
// ⇒ 判据 1/2/3/4 红,红在「真实登记表算不出启动图:依赖了不存在的设施
// [tracker(agent 需要)…]、等待环 [agent plugin-host]」。
// ★ 但**判据 5 也红** —— 见第 2 条,两条互为冗余。
//
// 2. 从 `internal/kernelmod/all/all.go` 去掉任一 `_ ".../kernelmod/xxx"` 行
// ⇒ 实测同上(模块根本没被编译进来)。
//
// 3. ★ 删掉**无下游依赖的叶子模块**的 init()(实测对象:multimodal)
// ⇒ **只有判据 5 红**,判据 1–4 全绿。
// 为什么 1–4 咬不住:没人依赖它 ⇒ 图能解、层顺序对、哨兵数对、无残留。
// 这正是判据 5 存在的唯一理由(初版判据在此**假绿**,见其函数注释)。
//
// 4. 把 Run 的层执行顺序改成逆序
// ⇒ 判据 1/2/3/4 红,但**红在装配失败**(第 4 层模块拿到 nil 依赖:
// 「agent 依赖的设施 “memory” 对应的数据为 nil」),
// 不是「层间顺序断言」咬的 —— 装配先于断言就炸了。
// ⇒ 把某一层整个跳过:**内核 panic**(llm.buildProviderManager 拿到
// 0x0 的 LuaVM,SIGSEGV)。仍是红的,但形态是崩溃不是可读断言。
// ★ 以上是 **ID:14 之前**的形态(2026-10-24 实测记录,保留下来
// 作为“为什么需要执行期校验”的原始证据)。
//
// ✅ **已完成(ID:14,提交 e70c2b5)**:kernelboot.Run 现在在**每层 Init
// 之前**先校“本层依赖 ⊆ 此前真跑完的层实际产出的设施”,缺就返回
// *DependencyNotReadyError(指名模块名 + 缺哪些设施),**本层一个
// Init 都不调**。用真实登记表实测,同一个变异变成可读报错:
// · 逆序 ⇒ 第 **0** 层就拒(produced 为空):
// 「模块 agent 缺设施 [docmem knowledge llm …];
// 模块 plugin-host 缺设施 [config docmem …]」
// · 跳过第 2 层 ⇒ 第 **2** 层拒:
// 「模块 stage-host 缺设施 [knowledge llm textmem]」
// **两种都不再是 panic / SIGSEGV**。
//
// ⇒ 下次看到本条,请不要据此认为“Run 不拦 nil 依赖”;它现在拦。
// 另:该行为的专用回归判据在 internal/kernelmod/all/runtime_dep_test.go
// (A 正序不误报 / B 交换两层 / C 删提供者整层)与
// internal/kernelboot/runtimedep_test.go(纯逻辑四形状 + 防预填自检)——
// 那两组比在本节做整机变异快得多,改代码时优先跑它们。
//
// 5. 某层不再登记关闭函数(模块忘调 AddCloser / 层边界钩子丢了)
// ⇒ 判据 3 红(哨兵数量不符)。与第 7 条同支路,实测由第 7 条覆盖。
//
// 6. 关闭改成与启动同序(正序)
// ⇒ 判据 3 红:实测「有 5 层,但只观测到 0 个…」/「关闭顺序不是严格逆序」。
//
// 7. 关闭函数不真正执行(CloseAll 变 no-op,等价于「漏 Stop/Close」)
// ⇒ 判据 3 红(哨兵 0 个)+ **判据 4 红**(「第 1 轮关闭后仍多 3 个
// goroutine(基线 11,现 14,允许抖动 2)」)。两条各自独立咬住。
//
// ## 为什么这些断言不需要"扫描源码"或"反射读模块内部"
//
// 全部断言都落在**装配产物**上:分层计划(Layers)、syncbus 的就绪顺序、
// 关闭函数的实际执行顺序、进程级 goroutine 数与文件系统状态。
// 源码怎么组织都无所谓 —— 只认"装配起来之后发生了什么"。
import (
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"reflect"
"runtime"
"sort"
"strconv"
"strings"
"testing"
"time"
internalConfig "github.com/JianFeeeee/HologramAgent/internal/config"
"github.com/JianFeeeee/HologramAgent/internal/kernelboot"
"github.com/JianFeeeee/HologramAgent/internal/kernelmod"
// ★ 判据与被测对象现在同属一个包(internal/kernelmod/all):
// 本文件就在聚合 import 的**那一层**里。因此不需要(也不能)再
// 空白 import 自己 —— 只要本包被编译,all.go 的聚合 import 就已生效。
// 这正是迁移的收益之一:判据用的是生产同一条 import 链,而非旁路。
)
// ─────────────────────────── 整机夹具 ───────────────────────────
// machine 是一台**真实装配起来**的内核(全部模块都是 kernelmod 里的真货)。
type machine struct {
t *testing.T
ks *kernelboot.KernelState
bus *kernelboot.SyncBus
// plan 是真实登记表算出的分层计划(启动图)。
plan *kernelboot.Plan
// res 是执行结果(层数 / 峰值并发 / 完成顺序)。
res *kernelboot.RunResult
// dataDir 是本机的数据目录(真实文件系统,不是内存桩)。
dataDir string
// layerClosers 记录"每层边界登记的关闭函数"被调用的层号顺序。
// 这是"严格逆序关闭"的**观测点**:层边界处 AddCloser 一个哨兵,
// 它必然排在该层所有模块的关闭函数**之后**(OnLayerDone 在层内 Init
// 全部完成后才跑),所以哨兵的调用顺序 = 层的关闭顺序。
layerClosers []int
closed bool
}
// layerSentinelFacilityPrefix 用于在断言消息里区分"哨兵"与"真模块"。
const shutdownProbeName = "boot-graph-test/layer-probe"
// bootMachine 用真实登记表装配一台整机。
//
// 参数 httpAddr 走 127.0.0.1:0(内核会把它传给 webui 插件):
// 不占固定端口 ⇒ 并发跑 CI/本地多次不会 "bind: address already in use"
// (实测:写死默认 :8080 时本机起两次就红)。
func bootMachine(t *testing.T) *machine {
t.Helper()
dir := t.TempDir()
// 只建**真实启动会建**的目录(与 cmd/homed 的 ensureDataDirs 对齐)。
for _, d := range []string{
"memory", "memory/text", "memory/documents", "knowledge",
"adapters", "agentfs", "plugins",
} {
if err := os.MkdirAll(filepath.Join(dir, d), 0o755); err != nil {
t.Fatalf("建数据目录: %v", err)
}
}
reg := internalConfig.NewConfigRegistry(filepath.Join(dir, "config.db"))
// ★ 注册表不在这里 t.Cleanup 关闭:本判据会在**同一个测试**里反复启停
// 多轮,t.Cleanup 要到测试结束才跑 ⇒ 每轮都留一个 *sql.DB 的
// connectionOpener goroutine 活着 ⇒ 泄漏判据会被自家夹具污染
// (实测:3 轮后 goroutine 数 2→5,逐轮 +1,全部来自 database/sql)。
// 关闭点放到 shutdown()(与生产的停机序列同处),并由 machine 保证只关一次。
ks := kernelboot.NewKernelState()
ks.CfgReg = reg
ks.DataDir = dir
ks.AgentWorkDir = filepath.Join(dir, "agentfs")
ks.Cfg = reg.ToConfig()
ks.Cfg.Daemon.DataDir = dir
ks.Cfg.Plugin.Dir = filepath.Join(dir, "plugins")
ks.HTTPAddr = "127.0.0.1:0"
mods := kernelmod.Registered()
if len(mods) == 0 {
t.Fatal("★ 真实登记表是空的 —— `internal/kernelmod/all` 的聚合 import 断了")
}
if d := kernelmod.Duplicates(); len(d) > 0 {
t.Fatalf("★ 模块名重复注册: %v", d)
}
plan, err := kernelboot.Build(mods)
if err != nil {
t.Fatalf("★ 真实登记表算不出启动图(漏挂模块 / 缺依赖 / 环): %v", err)
}
bus := kernelboot.NewSyncBus()
// 与生产 RunKernelModules 同序:先 MarkDeclared(让拼错校验有真相源),
// 再 Run(Run 内部还会 MarkDeclared,幂等)。
var declared []string
for _, layer := range plan.Layers {
for _, m := range layer {
declared = append(declared, m.Provides()...)
}
}
bus.MarkDeclared(declared)
m := &machine{t: t, ks: ks, bus: bus, plan: plan, dataDir: dir}
res, err := kernelboot.Run(plan, kernelboot.RunOptions{
State: ks,
Bus: bus,
MaxParallel: 4, // 与生产一致(cmd/homed 的 RunKernelModules)
OnLayerDone: func(idx int, _ []kernelboot.Module) {
// ★ 每层一个关闭哨兵:它登记在**该层所有模块之后**,
// 因此 CloseAll 的 LIFO 语义要求哨兵按层号**严格降序**被调用。
layer := idx
ks.AddCloser(func() { m.layerClosers = append(m.layerClosers, layer) })
},
})
if err != nil {
t.Fatalf("★ 整机装配失败: %v", err)
}
m.res = res
// ★ 停机走**生产路径**(cmd/homed 的 waitForShutdown 顺序),
// 而不是只调 CloseAll:漏关某层往往正是"生产路径少了一步"。
t.Cleanup(func() {
if !m.closed {
m.shutdown()
}
})
return m
}
// shutdown 执行与生产 `waitForShutdown` 一致的停机序列。
func (m *machine) shutdown() {
m.closed = true
if reg := m.ks.PluginRegistry; reg != nil {
reg.StopAll()
}
if trk := m.ks.Tracker; trk != nil {
if err := trk.Stop(); err != nil {
m.t.Logf("tracker.Stop: %v", err)
}
}
if m.ks.CfgReg != nil {
_ = m.ks.CfgReg.Flush()
// 生产里进程随即退出、句柄由内核回收;测试里必须显式关,
// 否则 *sql.DB 的 connectionOpener 会一直活着(见 bootMachine 注释)。
_ = m.ks.CfgReg.Close()
}
if sup := m.ks.Supervisor; sup != nil {
sup.Shutdown()
}
if err := m.ks.CloseAll(); err != nil {
m.t.Logf("CloseAll: %v", err)
}
}
// moduleNames 返回计划里全部模块名(跨层)。
func (m *machine) moduleNames() []string {
var out []string
for _, layer := range m.plan.Layers {
for _, mod := range layer {
out = append(out, mod.Name())
}
}
sort.Strings(out)
return out
}
// ─────────────────────── 判据 1:整机全部模块就位 ───────────────────────
// ★★★ 判据:**真实登记表里的每个模块都进了图,且其设施全部就绪**。
//
// 防的失败方式:「模块建了但没挂上」—— 新建 `internal/kernelmod/foo/mod.go`
// 写了类型和 `init()`,但忘了在 `all/all.go` 加聚合 import,或忘了写
// `init()` 里的 Register。后果不是编译错,而是该模块**静默不参与装配**:
// 它提供的设施永远不就绪,依赖它的插件**永不启动**(且无任何报错)。
//
// ★ 本判据的**能力边界(实测)**:只有「漏挂的模块被别人依赖」时才红。
//
// 漏掉的是**叶子模块**(没人 Requires 它,如 multimodal)时,
// 本判据连同判据 2/3/4 **全部照绿** —— 图照样能解、层照样对、
// 哨兵照样齐、也没东西可残留。
// 那种形态由判据 5(TestEveryRegisteredModuleIsCompiledIn)单独咬住,
// 它的真相源是源码里的类型与方法集,不依赖 Register 调用点。
// ⇒ 别把本判据当成「漏挂」的通用防线,两判据是互补关系,不是冗余。
func TestRealRegistryAssemblesWholeMachine(t *testing.T) {
m := bootMachine(t)
registered := map[string]bool{}
for _, name := range kernelmod.Names() {
registered[name] = true
}
inPlan := map[string]bool{}
for _, name := range m.moduleNames() {
if inPlan[name] {
t.Fatalf("★ 模块 %s 在启动图里出现多次(分层去重失败)", name)
}
inPlan[name] = true
}
// ① 双向集合相等:登记了却没进图 / 进了图却不在登记表,都是缺陷。
var missingInPlan, missingInRegistry []string
for n := range registered {
if !inPlan[n] {
missingInPlan = append(missingInPlan, n)
}
}
for n := range inPlan {
if !registered[n] {
missingInRegistry = append(missingInRegistry, n)
}
}
sort.Strings(missingInPlan)
sort.Strings(missingInRegistry)
if len(missingInPlan) > 0 {
t.Fatalf("★ 已登记但没进启动图的模块: %v —— 模块静默消失,"+
"它提供的设施永不就绪(依赖它的插件会永不启动且无报错)", missingInPlan)
}
if len(missingInRegistry) > 0 {
t.Fatalf("★ 在启动图里但不在登记表的模块: %v", missingInRegistry)
}
// ② 每个模块声明的设施都必须真的「已就绪」(不是"登记过",是"就绪")。
// ★ 这两个状态必须分开断言:MarkDeclared 会把全部 Provides 记成
// known("登记过"),所以只查 Known 的话,一个模块哪怕从没跑过
// 也"存在"——恰恰漏掉本判据要抓的失败。
snap := m.bus.Snapshot()
var neverReady []string
for _, layer := range m.plan.Layers {
for _, mod := range layer {
for _, f := range mod.Provides() {
if !snap[f] {
neverReady = append(neverReady, mod.Name()+"→"+f)
}
}
}
}
sort.Strings(neverReady)
if len(neverReady) > 0 {
t.Fatalf("★ 这些设施的提供者跑过了却没被标成就绪: %v\n"+
" 后果:插件声明依赖它会**永远等不到**(静默不启动)", neverReady)
}
// ③ 全部层都执行完,且内核完成信号已发(未声明依赖的插件的启动信号)。
if m.res.LayerCount != len(m.plan.Layers) {
t.Fatalf("★ 只执行了 %d/%d 层", m.res.LayerCount, len(m.plan.Layers))
}
if !m.bus.KernelComplete() {
t.Fatal("★ 全部层跑完却没有 MarkKernelComplete —— 未声明依赖的插件永不拉起")
}
// ④ 每个模块的 Init 都被调过:就绪顺序里应包含全部 Provides,
// 且不得出现"从未被任何模块声明"的野设施。
readySet := map[string]bool{}
for _, f := range m.bus.ReadyInOrder() {
readySet[f] = true
}
for _, layer := range m.plan.Layers {
for _, mod := range layer {
for _, f := range mod.Provides() {
if !readySet[f] {
t.Errorf("★ 设施 %s(%s)不在 ReadyInOrder 里", f, mod.Name())
}
}
}
}
t.Logf("整机就绪:%d 层 / %d 模块,峰值层内并发 %d,就绪设施 %d 个",
m.res.LayerCount, len(m.moduleNames()), m.res.MaxConcurrent, len(readySet))
}
// ─────────────────── 判据 2:层间顺序严格符合依赖拓扑 ───────────────────
// ★★★ 判据:**层间必须严格有序,层内两两无依赖**。
//
// 防的失败方式:
// - 执行顺序被改(正序→逆序、或某层被提前/推迟)⇒ 消费者先于提供者
// - 分层退化成"一层装完"(并行度归零,等价于回到串行 17 步)
// - 有人把模块塞进错误的层(图被手改 / 层号漂移)
func TestLayerTopologyIsRespected(t *testing.T) {
m := bootMachine(t)
// ① 逐层检查:本层每个模块的 Requires 只能由**更早的层**提供。
// 这就是 BFS 分层的定义(第 k 层的前驱全在 <k 层)。
providedByLayer := map[string]int{} // 设施 -> 首次被提供的层号
var violations []string
for li, layer := range m.plan.Layers {
// 先检查(用"更早层"的累积结果),再登记本层提供的设施。
for _, mod := range layer {
for _, req := range mod.Requires() {
got, ok := providedByLayer[req]
if !ok {
violations = append(violations,
mod.Name()+" 依赖 "+req+",但它的提供者在**更晚**的层(或不存在)")
continue
}
if got >= li {
violations = append(violations,
mod.Name()+"(第"+itoa(li)+"层)依赖 "+req+
"(第"+itoa(got)+"层)—— 层间顺序必须严格")
}
}
}
for _, mod := range layer {
for _, f := range mod.Provides() {
if _, seen := providedByLayer[f]; !seen {
providedByLayer[f] = li
}
}
}
}
if len(violations) > 0 {
sort.Strings(violations)
t.Fatalf("★ 层间顺序错了:\n %s", strings.Join(violations, "\n "))
}
// ② 层内两两无依赖(否则它们不该同层:其中一个必然在更晚的层)。
// 这是"同层可并行的前提",算法若坏了这条会先红。
for li, layer := range m.plan.Layers {
own := map[string]bool{}
for _, mod := range layer {
for _, f := range mod.Provides() {
own[f] = true
}
}
for _, mod := range layer {
for _, req := range mod.Requires() {
if own[req] {
t.Fatalf("★ 第 %d 层内部存在依赖:%s 依赖同层的 %s —— "+
"同层并行会读到未初始化的依赖", li, mod.Name(), req)
}
}
}
}
// ③ 观测到的就绪顺序必须与分层一致:某设施被标记就绪时,
// 它所在层的**全部前驱层**都已就绪。用 ReadyInOrder(真实写入顺序)
// 而不是 Layers(计划),这样才能咬住"执行时乱序"。
readyAt := map[string]int{}
for i, f := range m.bus.ReadyInOrder() {
readyAt[f] = i
}
for li, layer := range m.plan.Layers {
for _, mod := range layer {
for _, req := range mod.Requires() {
reqLayer := providedByLayer[req]
if reqLayer >= li {
continue // 已在 ① 报过
}
// 前驱设施必须先于本模块任一设施进入就绪序列。
for _, f := range mod.Provides() {
if readyAt[req] > readyAt[f] {
t.Fatalf("★ 就绪顺序反了:%s(%s) 先于其依赖 %s 就绪",
f, mod.Name(), req)
}
}
}
}
}
// ④ 分层必须真的带来并行(否则"图"退化成一条串行链,等于没做)。
// ★ 不写死层数/模块数:真实登记表增删模块时这条仍然成立。
if len(m.plan.Layers) >= len(m.moduleNames()) {
t.Fatalf("★ 无并行度:%d 层 / %d 模块(等价于全串行)",
len(m.plan.Layers), len(m.moduleNames()))
}
if m.res.MaxConcurrent < 2 {
t.Fatalf("★ 峰值层内并发只有 %d —— 同层并行没生效", m.res.MaxConcurrent)
}
}
// ─────────────────── 判据 3:关闭严格逆序 ───────────────────
// ★★★ 判据:**关闭顺序 = 启动顺序的严格逆序(按层)**。
//
// 防的失败方式:
// - 某层漏关(模块忘 AddCloser / 层边界钩子被删)⇒ 哨兵少一个
// - 关闭顺序改成与启动同序(正序)⇒ 先关被依赖者,依赖者随后用到已关资源
// - CloseAll 的 LIFO 语义被改(例如排序后执行)
func TestShutdownIsStrictlyReverseLayerOrder(t *testing.T) {
m := bootMachine(t)
layers := len(m.plan.Layers)
// 先停机再清点:哨兵是在 CloseAll 执行时才被调用的,装配刚完成时它
// 只是"登记了"。
m.shutdown()
got := m.layerClosers
if len(got) != layers {
t.Fatalf("★ 有 %d 层,但只观测到 %d 个层边界关闭哨兵被调用(顺序 %v)—— "+
"说明有层在关闭时被漏掉了(该层的模块资源不会释放)",
layers, len(got), got)
}
want := make([]int, layers)
for i := 0; i < layers; i++ {
want[i] = layers - 1 - i // 严格降序 = 启动顺序的逆序
}
if !equalIntSlice(got, want) {
t.Fatalf("★ 关闭顺序不是严格逆序:\n 实际(关闭顺序)= %v\n"+
" 期望(启动的逆序)= %v\n"+
" 正序关闭 ⇒ 先关被依赖者,依赖它的模块随后用到已关的资源", got, want)
}
// 关闭后 Closers 必须被消费干净(否则下次 CloseAll 会重跑已关资源)。
if n := len(m.ks.Closers); n != 0 {
t.Fatalf("★ CloseAll 之后仍残留 %d 个关闭函数(重复执行会二次释放)", n)
}
t.Logf("关闭顺序(层号)= %v —— 严格逆序 ✓", got)
}
// ─────────────────── 判据 4:关闭后无残留 ───────────────────
// goroutineSlack 是允许的 goroutine 抖动上限。
//
// ★ 为什么是 2 而不是 0:
// `testing` 自身在用例执行期间会有短暂 goroutine(如 -test.timeout 的
// 定时器、t.Cleanup 的调度),且 Go 运行时的 GC worker 会在
// runtime.GC() 后短暂存活。实测(本机 + CI 同构)关闭后
// 差值恒为 0,留 2 的余量只为吸收这种框架级抖动 —— 不是用来掩盖泄漏的
// (泄漏的量级是"每层一个轮询循环",即 ≥5,远超 2)。
const goroutineSlack = 2
// ★★★ 判据:**关闭后进程回到基线:无 goroutine 泄漏、无资源残留**。
//
// 防的失败方式:
// - 某模块的停止函数没跑(轮询/回调循环继续跑)⇒ goroutine 不回落
// - 句柄没关(DB/文件/HTTP server)⇒ 临时目录删不掉
// - 停机路径**越关越多**(关闭时又注册订阅/起协程)⇒ 反复启停会累积
//
// 做法:连续启停整机 3 轮,每轮都要求回落到基线,且差值不随轮次增长。
// 单轮回落可能是"碰巧",多轮不增长才能排除"每轮漏一点"。
func TestShutdownLeavesNoResidue(t *testing.T) {
baseline := settleGoroutines(t, 1<<30) // 先让运行时安静下来
t.Logf("基线 goroutine 数 = %d", baseline)
const rounds = 3
prevAfter := baseline
for round := 0; round < rounds; round++ {
m := bootMachine(t)
peak := runtime.NumGoroutine()
subscribersAfterBoot := countBusSubscribers(t, m.bus)
dir := m.dataDir
m.shutdown()
// ① goroutine 回落到基线(有界重试,不写死 sleep)。
after := settleGoroutines(t, baseline+goroutineSlack)
delta := after - baseline
if delta > goroutineSlack {
t.Fatalf("★ 第 %d 轮关闭后仍多 %d 个 goroutine(基线 %d,现 %d,允许抖动 %d)\n"+
" 这通常意味着某层模块的停止函数没跑(轮询/回调循环还活着)\n%s",
round+1, delta, baseline, after, goroutineSlack, goroutineStacks())
}
// ② 绝对水位不得逐轮抬升(排除"每轮漏一点"的慢泄漏)。
//
// ★ 比的是**绝对 goroutine 数**而不是相对第一轮基线的差值:
// 测试框架自身(-test.timeout 定时器、t.Cleanup 调度、GC worker)
// 会漂 1 个,用差值会在第 2 轮把框架抖动误报成泄漏。
// 真泄漏的量级是"每层一个轮询循环"(≥5/轮),逐轮抬升必然可见。
if round > 0 && after > prevAfter {
t.Fatalf("★ 关闭后的 goroutine 水位在抬升:第 %d 轮 %d > 上一轮 %d —— "+
"反复启停会累积泄漏\n%s", round+1, after, prevAfter, goroutineStacks())
}
prevAfter = after
// ③ 临时目录必须删得掉:有未关闭的句柄/未卸载的 overlay 时会 EBUSY。
// 这是"漏释放资源"最直接的外部证据(比看代码可靠)。
if err := removeAllWithRetry(dir, 20); err != nil {
var left []string
_ = filepath.Walk(dir, func(p string, fi os.FileInfo, err error) error {
if err == nil && fi != nil {
rel, _ := filepath.Rel(dir, p)
left = append(left, rel)
}
return nil
})
sort.Strings(left)
t.Fatalf("★ 关闭后临时目录删不掉(有句柄/挂载未释放): %v\n 残留项: %v",
err, left)
}
// ④ syncbus 订阅者不得在关闭路径上**增长**。
//
// ★ 为什么断言"不增长"而不是"归零":`SyncBus` 刻意**没有**
// Unsubscribe(它是启动期一次性状态快照,随本次装配的 bus 一起
// 被丢弃)。所以绝对归零需要给内核加 API —— 那是业务改动,
// 不在本判据的权限内。真正的泄漏形态是"关闭时又挂上了订阅"
// (每轮+1),这条能咬住;绝对归零作为已知缺口单独报出。
subsAfterShutdown := countBusSubscribers(t, m.bus)
if subsAfterShutdown > subscribersAfterBoot {
t.Fatalf("★ 关闭路径**新增**了 syncbus 订阅者:装配后 %d → 关闭后 %d "+
"—— 关闭时不该再挂回调(会持有已停止的对象)",
subscribersAfterBoot, subsAfterShutdown)
}
t.Logf("第 %d 轮:基线 %d,装配峰值 %d,关闭后 %d(差 %d),"+
"订阅者 %d→%d,临时目录已清理",
round+1, baseline, peak, after, delta,
subscribersAfterBoot, subsAfterShutdown)
}
}
// ──────────── 判据 5:登记表必须覆盖源码树(独立真相源) ────────────
// ★★★ 判据:**源码树里每个「实现了 kernelboot.Module 四方法」的类型,
// 其 Name() 必须出现在登记表里**。
//
// ## 真相源为什么必须是「接口形状」而不是 Register 调用点(初版的教训)
//
// 「漏挂一个模块」有两种形态,初版只抓得住第二种:
//
// a. 写好了 mod.go(类型 + init()),但 all/all.go 少一行聚合 import
// ⇒ Register 调用点**仍在源码里** ⇒ 扫得到 ⇒ 抓得住
// b. 连 init() 都没写 / 写了又删了(其余三方法齐全)
// ⇒ Register 调用点不存在 ⇒ 初版扫描的入口本身消失
// ⇒ 源码侧与登记表侧**同时少一个** ⇒ 集合比对照常相等 ⇒ 假绿
//
// b 正是「模块静默消失」最危险的形态:四方法写全了、编译也过、
// 只是没人调 Register。把「要检测的对象」同时当「检测入口」,
// 入口一被破坏判据就失去视野 —— 与 ID:16/20/23 记的三次判据假绿同病:
// **真相源与被测对象同源**。
//
// ⇒ 换成与 Register 无关的真相源:**类型 + 方法集**。
// 模块哪怕不 Register、不被 import,Name/Requires/Provides/Init 四个方法
// 仍写在源码里(AST 按「目录+类型名」复合键收集,四方法齐全即视为模块,
// 取其 Name() 的字符串字面量)。
//
// ## 已排除的误报面(设计时逐个实测)
//
// · 类型重名:本仓大量类型都叫 `Module`,**只按类型名做键会撞成一堆**
// (实测撞成只剩 2 个)⇒ 必须用 目录+类型名 复合键
// · 指针接收者:agent 的方法是 `func (*Module) Name()`,
// AST 要同时处理 *ast.Ident 与 *ast.StarExpr
// · 非字面量 Name():**显式报错**,不静默跳过(否则该模块从视野消失)
// · `_test.go` / `all/`(聚合 import 包)/ `testdata/`:不扫
//
// 实测基线:本仓该法恰扫出 17 个类型,与登记表 17 个名字一一对应(零误报)。
//
// 失败面(**已实测咬红**,不是注释声称):
//
// · 删任一模块的 init()(含无下游依赖的叶子模块 multimodal)⇒ 红
// · 删 all/all.go 任一聚合 import ⇒ 红(该类型四方法仍在 ⇒ 源码侧多一个)
// · 删掉整个模块目录 ⇒ 反向断言红(登记表里有、源码树里没有)
func TestEveryRegisteredModuleIsCompiledIn(t *testing.T) {
const modRoot = ".."
fromSource := scanModuleTypes(t, modRoot)
if len(fromSource) == 0 {
t.Fatal("★ 源码扫描一个模块都没找到 —— 扫描逻辑失效(判据会永远绿)")
}
registered := map[string]bool{}
for _, n := range kernelmod.Names() {
registered[n] = true
}
var missing []string
for name, file := range fromSource {
if !registered[name] {
rel, _ := filepath.Rel(modRoot, file)
missing = append(missing, name+"("+rel+")")
}
}
sort.Strings(missing)
if len(missing) > 0 {
t.Fatalf("★ 源码树里有 %d 个「四方法齐全的模块类型」**没被登记**(模块静默消失):\n %s\n"+
" 修复:给该类型补 `func init() { kernelmod.Register(X{}) }`,"+
"并在 internal/kernelmod/all/all.go 加聚合 import\n"+
" 后果:它提供的设施永不就绪 ⇒ 依赖它的插件永不启动,且无任何报错",
len(missing), strings.Join(missing, "\n "))
}
// 反向:登记表里的每个模块名都应能在源码树里找到(防夹具污染生产表)。
var phantom []string
for name := range registered {
if _, ok := fromSource[name]; !ok {
phantom = append(phantom, name)
}
}
sort.Strings(phantom)
if len(phantom) > 0 {
t.Fatalf("★ 登记表里有 %v,但源码树里找不到四方法齐全的类型 —— "+
"登记项可能是测试夹具或残留", phantom)
}
t.Logf("源码树扫描到 %d 个模块类型,登记表 %d 个,双向覆盖 ✓",
len(fromSource), len(registered))
}
// scanModuleTypes 按「目录+类型名」复合键收集模块类型(独立真相源)。
//
// 判定:Name/Requires/Provides/Init 四方法齐全 ⇒ 是模块类型;
// 取 Name() 的字符串字面量作为模块名。返回 模块名 → 定义文件路径。
func scanModuleTypes(t *testing.T, root string) map[string]string {
t.Helper()
fset := token.NewFileSet()
type key struct{ dir, typ string }
methods := map[key]map[string]bool{}
nameLit := map[key]string{}
nameFile := map[key]string{}
err := filepath.Walk(root, func(path string, fi os.FileInfo, err error) error {
if err != nil || fi == nil {
return nil
}
if fi.IsDir() {
switch fi.Name() {
case "all", "testdata":
// all 是聚合 import 包(自身不定义模块类型);testdata 不被编译。
return filepath.SkipDir
}
return nil
}
if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
return nil
}
f, perr := parser.ParseFile(fset, path, nil, 0)
if perr != nil {
return nil // 语法错误交给编译器报,这里不当判据失败
}
dir := filepath.Dir(path)
for _, d := range f.Decls {
fn, ok := d.(*ast.FuncDecl)
if !ok || fn.Recv == nil || len(fn.Recv.List) == 0 {
continue
}
var typ string
switch rt := fn.Recv.List[0].Type.(type) {
case *ast.Ident:
typ = rt.Name
case *ast.StarExpr:
if id, ok := rt.X.(*ast.Ident); ok {
typ = id.Name // 指针接收者(如 agent 的 *Module)
}
}
if typ == "" {
continue
}
k := key{dir, typ}
if methods[k] == nil {
methods[k] = map[string]bool{}
}
methods[k][fn.Name.Name] = true
if fn.Name.Name == "Name" && fn.Body != nil {
for _, st := range fn.Body.List {
ret, ok := st.(*ast.ReturnStmt)
if !ok || len(ret.Results) != 1 {
continue
}
lit, ok := ret.Results[0].(*ast.BasicLit)
if !ok || lit.Kind != token.STRING {
continue
}
v, uerr := strconv.Unquote(lit.Value)
if uerr != nil {
continue
}
nameLit[k] = v
nameFile[k] = path
}
}
}
return nil
})
if err != nil {
t.Fatalf("扫描源码树: %v", err)
}
out := map[string]string{}
for k, ms := range methods {
complete := true
for _, want := range []string{"Name", "Requires", "Provides", "Init"} {
if !ms[want] {
complete = false
break
}
}
if !complete {
continue
}
name, ok := nameLit[k]
if !ok {
// ★ 显式失败而不是跳过:非字面量 Name() 会让该模块从判据视野消失,
// 那就又是一次静默漏测(初版判据就是这么假绿的)。
t.Fatalf("★ %s 里类型 %s 四方法齐全但 Name() 不是字符串字面量 —— "+
"本判据无法静态解析它。请把 Name() 改成 `return \"...\"`,"+
"或在 scanModuleTypes 里补上解析(**不要**静默跳过)",
nameFile[k], k.typ)
}
out[name] = nameFile[k]
}
return out
}
// ─────────────────────────── 辅助 ───────────────────────────
// settleGoroutines 让 goroutine 数回落到 <= target,返回最终观测值。
//
// 有界重试(不是写死 sleep):每轮 GC + 短暂让出,最多 100 轮(约 2s)。
// 轮次上限的存在让"真泄漏"以超时后的真实数字报出来,而不是永远等下去。
func settleGoroutines(t *testing.T, target int) int {
t.Helper()
var n int
for i := 0; i < 100; i++ {
runtime.GC()
n = runtime.NumGoroutine()
if n <= target {
return n
}
time.Sleep(20 * time.Millisecond)
}
return n
}
// removeAllWithRetry 反复尝试删目录(有界)。
//
// 为何需要重试:关闭函数的先后顺序里,DB 的 Close 与文件句柄的释放
// 可能比内核的 dentry 回收稍晚;Linux 上偶发 EBUSY。重试上限保证
// "真删不掉"不会变成无限等待。
func removeAllWithRetry(dir string, tries int) error {
var err error
for i := 0; i < tries; i++ {
if err = os.RemoveAll(dir); err == nil {
return nil
}
time.Sleep(50 * time.Millisecond)
}
return err
}
// countBusSubscribers 读 syncbus 上的订阅者数量。
//
// 只读反射(不 Set、不 Interface),用于把"关闭时挂订阅"这种泄漏
// 变成可断言的数字。字段名/类型变化时本函数会 fatalf —— 那是判据
// 失去观测点,必须显式修,不能静默变成 0。
func countBusSubscribers(t *testing.T, bus *kernelboot.SyncBus) int {
t.Helper()
if bus == nil {
return 0
}
f := reflect.ValueOf(bus).Elem().FieldByName("onUpdate")
if !f.IsValid() || f.Kind() != reflect.Slice {
t.Fatalf("syncbus 的订阅者字段不再是 slice(判据失去观测点)")
}
return f.Len()
}
func equalIntSlice(a, b []int) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func itoa(n int) string {
if n == 0 {
return "0"
}
var buf [20]byte
i := len(buf)
neg := n < 0
if neg {
n = -n
}
for n > 0 {
i--
buf[i] = byte('0' + n%10)
n /= 10
}
if neg {
i--
buf[i] = '-'
}
return string(buf[i:])
}
// goroutineStacks 在泄漏时抓全量栈(带进失败消息,便于定位是哪一层没关)。
func goroutineStacks() string {
buf := make([]byte, 1<<20)
n := runtime.Stack(buf, true)
return "--- goroutine 栈 ---\n" + string(buf[:n])
}

View File

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

View File

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

View File

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

View File

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