Files
HomeAgent/internal/plugin/builtin_skill_test.go
JianFeeeee d65b742bd7 fix(plugin): 修两处持锁自死锁(CI 双红真因)+ tools_ready 事件声明
## 死锁(这是 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 全通过。
2026-10-25 18:25:37 +08:00

233 lines
7.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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。")
}
}