mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 06:47:29 +00:00
## 死锁(这是 CI 连续两次红的真因) `StopAll` 与 `StopPlugin` 都在持有 `r.mu` **写锁**时调用 `RemoveBuiltinSkill` → `skillDirOf` → `r.mu.RLock()` ⇒ **自死锁**。 后果不是报错而是**卡死**: - internal/plugins 卡 300s 超时(TestCapability_RestrictedPluginStillLoads) - internal/plugin 卡 840s 超时(TestStopPluginDoesNotTouchPersistentState) - CI 的 `go test ./...` 与 `go test -race` 双双红 修法:拆出**不加锁**的内部版本,持锁路径只走它: - `skillDirFrom(dataDir, name)`(纯函数) - `removeBuiltinSkillLocked(pluginName, p, dataDir)`(显式传 dataDir, 避免"看起来没取锁、一改就取锁"的隐患) - `announceSkillRemovedLocked`(持锁下直读 r.evBus) 修后:internal/plugins 300s → 12s,internal/plugin 840s → 0.7s。 ## 防回归判据 `TestStopAllAndStopPluginDoNotDeadlock`:死锁**不返回**,普通断言抓不到, 所以用**超时**当判据(5 秒未返回即失败),并指明应改用 Locked 版本。 ## tools_ready 事件声明 新增的 tools_ready 触发 `TestEveryPublishedEventHasAnOutwardPath` (该判据防"事件发了、客户端收不到"的静默失效)。 tools_ready 是**内核内部**就绪信号(plugin-host → seq 等订阅者), 不是对话流程事件,前端无需感知 ⇒ 在 allowed 里显式声明理由。 ## 验证 go build ./... / go vet ./... / go test -p 1 ./... / -race 全通过。
233 lines
7.8 KiB
Go
233 lines
7.8 KiB
Go
package plugin
|
||
|
||
// 内置插件随包 SKILL 生命周期的判据。
|
||
//
|
||
// ## 为什么这些判据必须存在
|
||
//
|
||
// skill 没装上/没摘掉,**插件本身照常工作**(工具都在),只是模型的行为变了:
|
||
// · 缺 skill ⇒ 模型看不到能力摘要,几乎不会用("seq 很少被使用"的直接原因)
|
||
// · 停用后 skill 还在 ⇒ 模型看得到却调不到,拿到一串"工具不存在"且不知原因
|
||
//
|
||
// 两种都是**静默失效**:没有任何报错,只能靠判据钉住。
|
||
|
||
import (
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/JianFeeeee/HologramAgent/internal/events"
|
||
sdk "github.com/JianFeeeee/HologramAgent/internal/sdk"
|
||
)
|
||
|
||
// fakeSkillPlugin 是一个实现 SkillProvider 的最小插件替身。
|
||
type fakeSkillPlugin struct {
|
||
name string
|
||
skills map[string]string
|
||
}
|
||
|
||
func (f *fakeSkillPlugin) Name() string { return f.name }
|
||
func (f *fakeSkillPlugin) Start(*sdk.PluginSDK) error { return nil }
|
||
func (f *fakeSkillPlugin) Stop() error { return nil }
|
||
func (f *fakeSkillPlugin) BuiltinSkills() map[string]string { return f.skills }
|
||
|
||
// plainPlugin 不实现 SkillProvider —— 绝大多数插件是这样,必须不受影响。
|
||
type plainPlugin struct{ name string }
|
||
|
||
func (p *plainPlugin) Name() string { return p.name }
|
||
func (p *plainPlugin) Start(*sdk.PluginSDK) error { return nil }
|
||
func (p *plainPlugin) Stop() error { return nil }
|
||
|
||
// testDataDir 读回注入的 data_dir(同包测试可直接读私有字段)。
|
||
func (r *Registry) testDataDir() string {
|
||
r.mu.RLock()
|
||
defer r.mu.RUnlock()
|
||
return r.dataDir
|
||
}
|
||
|
||
func newTestRegistryForSkills(t *testing.T) *Registry {
|
||
t.Helper()
|
||
r := NewRegistry()
|
||
r.SetDataDir(t.TempDir())
|
||
r.SetEventBus(events.NewBus())
|
||
return r
|
||
}
|
||
|
||
const sampleSkill = `---
|
||
name: demo-tools
|
||
description: 演示用 skill。
|
||
---
|
||
|
||
演示用 skill 的正文段落,模型会读到这一行作为摘要。
|
||
`
|
||
|
||
// ── 安装:写到约定位置(skillmgr 的扫描口径)──
|
||
|
||
func TestBuiltinSkill_InstallsToConventionPath(t *testing.T) {
|
||
r := newTestRegistryForSkills(t)
|
||
dataDir := r.testDataDir()
|
||
|
||
p := &fakeSkillPlugin{name: "demo", skills: map[string]string{"demo-tools": sampleSkill}}
|
||
r.InstallBuiltinSkill("demo", p)
|
||
|
||
file := filepath.Join(dataDir, "skills", "demo-tools", "SKILL.md")
|
||
data, err := os.ReadFile(file)
|
||
if err != nil {
|
||
t.Fatalf("SKILL.md 未落到约定位置 %s: %v(skillmgr 扫不到它)", file, err)
|
||
}
|
||
if string(data) != sampleSkill {
|
||
t.Fatal("落盘内容与插件提供的不一致")
|
||
}
|
||
}
|
||
|
||
// 不实现 SkillProvider 的插件必须被安静跳过(不能报错、不能创建目录)。
|
||
func TestBuiltinSkill_NonProviderIsNoop(t *testing.T) {
|
||
r := newTestRegistryForSkills(t)
|
||
r.InstallBuiltinSkill("plain", &plainPlugin{name: "plain"})
|
||
|
||
skillsDir := filepath.Join(r.testDataDir(), "skills")
|
||
if entries, err := os.ReadDir(skillsDir); err == nil && len(entries) > 0 {
|
||
t.Fatalf("无 skill 的插件不应产生任何目录,实际 %d 项", len(entries))
|
||
}
|
||
}
|
||
|
||
// 幂等:重复安装不报错,且不覆盖用户已改过的内容。
|
||
func TestBuiltinSkill_IdempotentNoOverwrite(t *testing.T) {
|
||
r := newTestRegistryForSkills(t)
|
||
dataDir := r.testDataDir()
|
||
p := &fakeSkillPlugin{name: "demo", skills: map[string]string{"demo-tools": sampleSkill}}
|
||
|
||
r.InstallBuiltinSkill("demo", p)
|
||
file := filepath.Join(dataDir, "skills", "demo-tools", "SKILL.md")
|
||
const edited = "# 用户改过的内容\n"
|
||
if err := os.WriteFile(file, []byte(edited), 0644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
r.InstallBuiltinSkill("demo", p)
|
||
got, _ := os.ReadFile(file)
|
||
if string(got) != edited {
|
||
t.Fatalf("重复安装覆盖了用户内容:期望 %q,实际 %q", edited, string(got))
|
||
}
|
||
}
|
||
|
||
// 空正文必须跳过(空的 SKILL 会让模型读到无内容,等于装了个假 skill)。
|
||
func TestBuiltinSkill_EmptyBodySkipped(t *testing.T) {
|
||
r := newTestRegistryForSkills(t)
|
||
r.InstallBuiltinSkill("demo", &fakeSkillPlugin{
|
||
name: "demo",
|
||
skills: map[string]string{"empty": " \n "},
|
||
})
|
||
dir := filepath.Join(r.testDataDir(), "skills", "empty")
|
||
if _, err := os.Stat(dir); err == nil {
|
||
t.Fatal("空正文不应创建 skill 目录")
|
||
}
|
||
}
|
||
|
||
// ── 卸载:停用时必须摘掉 ──
|
||
|
||
func TestBuiltinSkill_RemoveOnStop(t *testing.T) {
|
||
r := newTestRegistryForSkills(t)
|
||
dataDir := r.testDataDir()
|
||
p := &fakeSkillPlugin{name: "demo", skills: map[string]string{"demo-tools": sampleSkill}}
|
||
|
||
r.InstallBuiltinSkill("demo", p)
|
||
dir := filepath.Join(dataDir, "skills", "demo-tools")
|
||
if _, err := os.Stat(dir); err != nil {
|
||
t.Fatalf("前置:skill 应已安装: %v", err)
|
||
}
|
||
|
||
r.RemoveBuiltinSkill("demo", p)
|
||
if _, err := os.Stat(dir); !os.IsNotExist(err) {
|
||
t.Fatalf("停用后 skill 目录必须移除(否则模型看得到却调不到工具): err=%v", err)
|
||
}
|
||
}
|
||
|
||
// ── 事件:必须主动通告(启动全扫有时序空窗)──
|
||
|
||
func TestBuiltinSkill_AnnouncesDetected(t *testing.T) {
|
||
r := newTestRegistryForSkills(t)
|
||
bus := events.NewBus()
|
||
r.SetEventBus(bus)
|
||
|
||
var got []*events.Event
|
||
bus.Subscribe(events.EventSkillDetected, func(e *events.Event) { got = append(got, e) })
|
||
|
||
r.InstallBuiltinSkill("demo", &fakeSkillPlugin{
|
||
name: "demo",
|
||
skills: map[string]string{"demo-tools": sampleSkill},
|
||
})
|
||
|
||
if len(got) != 1 {
|
||
t.Fatalf("安装必须通告一次 skill_detected,实际 %d 次", len(got))
|
||
}
|
||
if p, _ := got[0].Payload["path"].(string); !strings.Contains(p, "demo-tools") {
|
||
t.Fatalf("payload.path 应指向 skill 目录,实际 %q", p)
|
||
}
|
||
if got[0].Source != "demo" {
|
||
t.Fatalf("Source 应为插件名,实际 %q", got[0].Source)
|
||
}
|
||
}
|
||
|
||
// 卸载时通告带 removed 标记:让 skillmgr 能从索引里摘掉。
|
||
func TestBuiltinSkill_AnnouncesRemoved(t *testing.T) {
|
||
r := newTestRegistryForSkills(t)
|
||
bus := events.NewBus()
|
||
r.SetEventBus(bus)
|
||
|
||
var got []*events.Event
|
||
bus.Subscribe(events.EventSkillDetected, func(e *events.Event) { got = append(got, e) })
|
||
|
||
p := &fakeSkillPlugin{name: "demo", skills: map[string]string{"demo-tools": sampleSkill}}
|
||
r.InstallBuiltinSkill("demo", p)
|
||
r.RemoveBuiltinSkill("demo", p)
|
||
|
||
if len(got) != 2 {
|
||
t.Fatalf("安装+卸载应各通告一次,实际 %d 次", len(got))
|
||
}
|
||
removed, _ := got[1].Payload["removed"].(bool)
|
||
if !removed {
|
||
t.Fatal("卸载通告必须带 removed=true(否则 skillmgr 分不清是新增还是移除)")
|
||
}
|
||
}
|
||
|
||
// ── 持锁路径不得调用加锁版(防自死锁)──
|
||
//
|
||
// 真实事故(2026-10-25):StopAll 与 StopPlugin 都在持有 r.mu 写锁时
|
||
// 调 RemoveBuiltinSkill → skillDirOf → r.mu.RLock() ⇒ **自死锁**。
|
||
//
|
||
// 后果不是报错而是**卡死**:internal/plugins 卡 300s、internal/plugin 卡 840s
|
||
// (go test 默认 10m/20m 超时才死),CI 的 go test ./... 与 -race 双双红。
|
||
//
|
||
// 死锁无法用普通断言抓(它不返回),所以这里用**超时**当判据:
|
||
// 正常应毫秒级返回,超过 5 秒即视为死锁。
|
||
func TestStopAllAndStopPluginDoNotDeadlock(t *testing.T) {
|
||
done := make(chan struct{})
|
||
go func() {
|
||
defer close(done)
|
||
|
||
// ① StopAll 路径
|
||
r1 := newTestRegistryForSkills(t)
|
||
p1 := &fakeSkillPlugin{name: "demo", skills: map[string]string{"demo-tools": sampleSkill}}
|
||
r1.InstallBuiltinSkill("demo", p1)
|
||
r1.StopAll()
|
||
|
||
// ② StopPlugin 路径(需要真的把插件注册进 r.plugins)
|
||
r2 := newTestRegistryForSkills(t)
|
||
p2 := &fakeSkillPlugin{name: "demo", skills: map[string]string{"demo-tools": sampleSkill}}
|
||
r2.InstallBuiltinSkill("demo", p2)
|
||
r2.mu.Lock()
|
||
r2.plugins["demo"] = p2
|
||
r2.mu.Unlock()
|
||
_ = r2.StopPlugin("demo")
|
||
}()
|
||
|
||
select {
|
||
case <-done:
|
||
case <-time.After(5 * time.Second):
|
||
t.Fatal("StopAll/StopPlugin 超时 —— 极可能是持锁调用了加锁版 " +
|
||
"RemoveBuiltinSkill(自死锁)。请改用 removeBuiltinSkillLocked。")
|
||
}
|
||
}
|