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

@ -61,6 +61,18 @@ func (r *Registry) skillDirOf(skillName string) string {
r.mu.RLock()
dataDir := r.dataDir
r.mu.RUnlock()
return skillDirFrom(dataDir, skillName)
}
// skillDirFrom 是 skillDirOf 的**不加锁**版本(纯函数)。
//
// ★ 为什么必须拆出来:StopAll 持有 r.mu **写锁**时调用 RemoveBuiltinSkill,
// 而 skillDirOf 要取 RLock ⇒ 自死锁。实测后果:internal/plugins 的
// TestCapability_RestrictedPluginStillLoads 卡在 cleanup 300s 超时,
// CI 的 go test ./... 与 -race 双双红(2026-10-25)。
//
// 规则:已持锁的调用路径只能用不加锁版本。
func skillDirFrom(dataDir, skillName string) string {
if dataDir == "" {
return ""
}
@ -122,12 +134,25 @@ func (r *Registry) InstallBuiltinSkill(pluginName string, p sdk.Plugin) {
// 而工具已被停用 —— 模型会拿到一连串"工具不存在"的错误,
// 且它无从知道原因是"插件被关了"。
func (r *Registry) RemoveBuiltinSkill(pluginName string, p sdk.Plugin) {
r.mu.RLock()
dataDir := r.dataDir
r.mu.RUnlock()
r.removeBuiltinSkillLocked(pluginName, p, dataDir)
}
// removeBuiltinSkillLocked 是 RemoveBuiltinSkill 的**不加锁**版本。
//
// 调用方必须已持有 r.mu(且不能再调任何会取锁的方法)。
// 由 StopAll 使用 —— 那里持写锁,走加锁版会自死锁。
// 显式传入 dataDir 而不是在里面读 r.dataDir:避免"看起来没取锁、
// 实际上一改就取锁"的隐患。
func (r *Registry) removeBuiltinSkillLocked(pluginName string, p sdk.Plugin, dataDir string) {
provider, ok := p.(SkillProvider)
if !ok {
return
}
for name := range provider.BuiltinSkills() {
dir := r.skillDirOf(name)
dir := skillDirFrom(dataDir, name)
if dir == "" {
continue
}
@ -137,7 +162,7 @@ func (r *Registry) RemoveBuiltinSkill(pluginName string, p sdk.Plugin) {
}
log.Printf("[plugin] %s: removed skill %q (plugin is stopped)", pluginName, name)
// 通知 skillmgr 从索引里摘掉(复用同一事件,负载带 removed 标记)。
r.announceSkillRemoved(pluginName, dir)
r.announceSkillRemovedLocked(pluginName, dir)
}
}
@ -164,10 +189,9 @@ func (r *Registry) announceSkill(pluginName, dir string) {
})
}
func (r *Registry) announceSkillRemoved(pluginName, dir string) {
r.mu.RLock()
// announceSkillRemovedLocked 发布"skill 已移除"(**不加锁**,供持锁路径调用)。
func (r *Registry) announceSkillRemovedLocked(pluginName, dir string) {
bus := r.evBus
r.mu.RUnlock()
if bus == nil {
return
}