mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-05 16:57:32 +00:00
## 起因(生产故障)
A2A 分诊助手(驻留子)把 terminal_create 当一次性命令执行器:
terminal_create(command='cd /usr/lib/node_modules/@deepseek-ai && ls -d */ | head -60 && …')
→ read /dev/ptmx: input/output error
→ 再开 term_7、term_8、term_9 …
链路拆开四层:
① cmd 插件 auto_start=false ⇒ 工具表里没有 cmd_run
② 需要 cmd-tools skill 说明"先 plugin_start cmd"
③ 但 skill 索引没进那个 agent 的提示词
④ 因为 skillIndex 只是根 agent 的字段、由 bootstrap 手工接线
④ 才是总根。而它之所以必须手工接线:agent/core 读不到内核的共享数据。
## 环的精确形状(实测,非推测)
kernelboot 因为持有 KernelState 而依赖 19 个业务包:
kernelboot ──▶ agent/core ──▶ …
──▶ plugin
──▶ security
──▶ memory / knowledge / text / coldcontext / …
而 kernelboot.go / scheduler.go / funcmodule.go / syncbus.go / lifecycle.go
**一行业务代码都不需要** —— 依赖全在 kernelstate.go 这一个数据文件里。
后果是四个底层包各自造通道:
core → Agent 字段(每种 agent 构造点都要记得传)
plugin → 12 个 SetXxx
sdk → SDKConfig 传参
security → 包级单例 Global() / RuntimeGlobal() / ConfigCodec()
四条通道是同一个根因的四个表现。
## 本次做的事
### 1. 抽出 internal/kstate(断环)
kernelstate.go / syncbus.go / lifecycle.go 搬出 kernelboot:
internal/kstate 纯数据 + 唯一挂载点(叶子,谁都能 import)
internal/kernelboot 装配引擎,零业务包
挂载点 API:Mount / Get(nil 安全)/ MountForTest(返回 restore,并行测试隔离)。
验证:core 现在能 import kstate(此前 import cycle not allowed)。
### 2. StageHost / Agent 字段存 any
它们是 core 自己的类型(core 是环的一极,这是真环)。按用户裁定的方向:
> 是其他包在kstate挂共享内容啊
存 any,消费方断言回具体类型 —— 与本仓既有的 PluginCatalog /
PluginScheduler / UpdateCapabilities 同型。
★ 其余字段保持**具体类型**,没有一律改 any。判据是"它会不会反向依赖
agent/core":实测 plugin / security / sdk 都不依赖 core,所以它们作为
具体类型不构成环,而类型化字段让"拼错/取错类型"是编译错误(本结构体的
设计原则)。
### 3. 插件声明式系统提示词段
SDK 新增 SystemPromptAPI(见 submodule 提交)。内核侧:
kstate.State.PromptSections *promptsec.Registry ← 唯一事实来源
Agent.renderSystemPrompt() 每轮重建时读它
skillmgr 从"实现 SkillIndexProvider 让内核来问"改为"声明自己的段":
DeclarePromptSection{Section:"skillmgr.skills", Order:100, Volatile:true}
并挂到全部变更点(启动全扫后、移交事件后、load/unload/create/install/
enable/disable 后、Stop 时撤销)。
### 4. 内核自身的提示词段也走同一管理器
按用户裁定「把现在内核所有 systemprompt 的相关注入都接入到这个统一的
systemprompt 管理器上」,把 buildSystemPrompt 里硬拼的十几块
(人格卡/任务提示词/记忆说明/输出规则/工具目录…)改为声明式:
kernelSections() 当轮计算 → 与插件段按 Order 合并渲染。
顺序由 Order 常量决定,改结构变成调常量而不是挪代码块。
## 判据(均经变异验证)
TestCoreCanImportKstate 真编译探针;环重现则红(实测)
TestKstateDoesNotDependOnCore 静态版,指向具体字段
TestKstateIsLeaf kstate 不得反依赖上层
TestRTChildSeesPromptSections 子 agent 能看到插件段
TestResidentSeesPromptSections 生产实际中招的那一类;变异后红(实测)
TestRootAgentSeesPromptSections 确认没改坏根路径
TestNoPromptSectionsMount 未装配时不 panic、不凭空出现段
TestDeclareTwiceRendersOnce ★ 回应"会不会多份":声明 5 次渲染 1 份
TestSectionRemovalTakesEffect Stop 后段消失
TestRuntimeUpdateVisibleToExistingAgent 同一实例下一轮看到新内容
TestSectionOrderIsRespected Order 生效且稳定
## 验证
go build ./... PASS
go vet ./... PASS
gofmt -l internal/ cmd/ pkg/ 干净
go test ./... -count=1 63 包全过,0 FAIL
干净检出(git worktree) build + 4 包测试全绿
## 未做(见 kernelplan.md)
T3 security 三单例收敛(含 Store 复用 Codec 的惰性迁移)
T5 plugin Setter 收敛
T6 防腐判据(部分已随本次落地)
414 lines
14 KiB
Go
414 lines
14 KiB
Go
package kernelboot
|
||
|
||
// syncbus + 插件调度器的判据(2026-10-16)。
|
||
//
|
||
// 设计见 docs/zh/kernel-modularization-design.md §2.6。
|
||
//
|
||
// 这组判据里**最重要的一条是"默认路径为零改动"**:
|
||
// 存量 42 个插件都不声明依赖,它们的启动时机必须逐字不变。
|
||
// 静默改变启动时机 = 静默引入竞态。
|
||
|
||
import (
|
||
"github.com/JianFeeeee/HologramAgent/internal/kstate"
|
||
"reflect"
|
||
"strings"
|
||
"sync"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
// ★★★ 判据(最关键):**未声明依赖的插件等内核完全初始化**(存量兼容)。
|
||
//
|
||
// 这是整个设计里风险最高的一处:若未声明的插件被"提前"拉起,
|
||
// 42 个存量插件的启动时机全部改变 ⇒ 静默竞态。
|
||
//
|
||
// 反例(若实现错误):把"未声明"当成"依赖集为空",而空集在数学上已满足
|
||
// ⇒ 第一层完成后就拉起 ⇒ 比现状早得多。
|
||
func TestSchedulerUndeclaredWaitsForKernelComplete(t *testing.T) {
|
||
bus := kstate.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 := kstate.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 := kstate.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 拿到的接口**没有**写方法 —— 如果给它 *kstate.SyncBus,
|
||
// 它就能谎报内核状态,让插件在依赖不存在时启动(病 A)。
|
||
func TestSchedulerCannotWriteSyncState(t *testing.T) {
|
||
// ★ 检查必须是**接口类型的方法集**,不是某个具体值。
|
||
//
|
||
// 我第一次写错了:用 `var st kstate.SyncState = kstate.NewSyncBus()` 再断言 `st.(writer)` ——
|
||
// 那检查的是**动态类型**(*kstate.SyncBus 当然有 MarkReady),判据当场报假红。
|
||
// 要约束的是"拿到 kstate.SyncState 的人**能不能**调写方法" ⇒ 必须查接口的方法集。
|
||
rt := reflect.TypeOf((*kstate.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("★ kstate.SyncState(插件管理器拿到的接口)暴露了写方法 %s —— "+
|
||
"它能谎报内核状态,让插件在依赖不存在时启动(病 A)", m)
|
||
}
|
||
}
|
||
}
|
||
// 反向确认:读方法必须在(否则接口退化到没用了)
|
||
for _, want := range []string{"IsReady", "Snapshot", "AllReady", "KernelComplete"} {
|
||
if _, ok := rt.MethodByName(want); !ok {
|
||
t.Errorf("kstate.SyncState 缺少读方法 %s", want)
|
||
}
|
||
}
|
||
// Scheduler 持有的字段类型也必须是 kstate.SyncState(不能是 *kstate.SyncBus)
|
||
st := reflect.TypeOf(Scheduler{})
|
||
f, ok := st.FieldByName("bus")
|
||
if !ok {
|
||
t.Fatal("Scheduler 应有 bus 字段")
|
||
}
|
||
if f.Type.String() != "kstate.SyncState" {
|
||
t.Fatalf("★ Scheduler.bus 的类型是 %s —— 必须是只读的 kstate.SyncState", f.Type)
|
||
}
|
||
}
|
||
|
||
// ★★★ 判据:**不伴随启动的插件即使依赖齐了也不自动拉起**(§2.6.6)。
|
||
func TestSchedulerAutoStartFalseIsNotLaunched(t *testing.T) {
|
||
bus := kstate.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 := kstate.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 := kstate.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 := kstate.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 := kstate.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 := kstate.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 := kstate.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 := kstate.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 := kstate.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 := kstate.NewSyncBus()
|
||
got := make(chan string, 4)
|
||
|
||
// 订阅者**读状态**(真实回调必然会读,这正是死锁触发条件)
|
||
bus.OnUpdate(func(s kstate.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
|
||
}
|