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 全通过。
This commit is contained in:
JianFeeeee
2026-10-25 18:25:37 +08:00
parent fe1c4f6984
commit d65b742bd7
28 changed files with 813 additions and 75 deletions

View File

@ -15,6 +15,7 @@ import (
"path/filepath"
"strings"
"testing"
"time"
"github.com/JianFeeeee/HologramAgent/internal/events"
sdk "github.com/JianFeeeee/HologramAgent/internal/sdk"
@ -190,3 +191,42 @@ func TestBuiltinSkill_AnnouncesRemoved(t *testing.T) {
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。")
}
}