mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-15 12:15:16 +00:00
fix(proc): stage 锁改可重入 + 协调器结束解绑与自愈,解 qq「重复申请 stage 锁」
## 生产现象 本机 homed(10-07 11:28~21:58)93+1 条: [stage] before_toolcall handler error: proc: qq.stage.invoke: 申请 stage 锁: proc: 插件 qq 重复申请 stage 锁(handler 内不应嵌套加锁) qq 的 before_toolcall 是它的**权限门**,每次工具调用都跑。 被拒 ⇒ 该 handler 永不执行 ⇒ **权限门失效**。 ## 走过的三条错路(记录以免重犯) ❌ 错路一:「内核没释放锁」。模板 handleStageInvoke 的加解锁完整配对 (unlocked 标志保证只解一次),不会漏解。 ❌ 错路二:「同一插件注册了两个 before_toolcall handler」。 实测线上 qq 是单进程(pid 恒定),且 `strings plugin.bin | grep beforeOwnToolcall` = 0 ⇒ 只有一份 handler。 ❌ 错路三(我自己实现的):把「重入即报错」改成「重入即等待」。 ★ 同进程重入就是自己等自己 ⇒ 30s 超时死锁,比原报错更糟。 跑判据时立刻挂 30s 才发现。**重入的正确解是可重入,不是等待。** ## 真正的根因与修法 `stageLock` 是每个协调器新建一个,而 `lockRegistry` 只 bind 从不解绑; 且 Acquire 对同插件重入直接报错。两个缺口叠加: 1. **Acquire 改为可重入**(新增 depth 计数) 同一插件可在同一 stage 注册多个 handler(内核 RegisterStageFor 用 append),它们并发扇出各发一次 stage.lock;同一进程内第二个进来时 已在临界区,本就不该再取互斥量。depth 减到 0 才真正释放 ——内层释放时若直接放锁,外层还在临界区就会被别人抢进去。 不同插件的互斥语义完全不变。 2. **endStage 在「最后离开者」分支解绑路由** 协调器结束时它就不该再被路由到;否则下一轮的 stage.lock 会打到 上一轮那只可能仍 held 的锁上。 3. **endStage 补泄漏自愈**(releaseIfLeaked) 插件异常路径(handler panic 被 recover 吞掉、RPC 写失败)会让 defer unlock 之前就 return,锁就永久挂着。锁的所有权在内核进程 (这正是 ForceRelease 存在的理由),那内核就必须能收尾。 ## 判据 stage_lock_reentrant_test.go(8 条)+ 改写 lock_test.go 那条旧判据。 ★ 旧判据 TestStageLock_ReentrantAcquireRejected 原本要求「重入必须被拒绝」 ——它在 2026-09 实验 9 里是对的(那时 Acquire 做成等待)。 生产证明「拒绝」本身是错的,已改名为 ...ReentrantAcquirePassesThrough, 并在注释里写明为什么改。 ★ **变异测试抓到判据两个缺口**(判据不红 = 判据没覆盖,不是实现没问题): · 去掉 releaseIfLeaked → 判据不红 ⇒ 补 TestEndStage_ReleasesLeakedLock · 内层 Release 直接放锁 → 判据不红 ⇒ 补 TestStageLock_InnerReleaseKeepsOuterLock (原来只断言最终状态,bug 在中间状态) 四个变异重做后全红:还原报错 / 去掉解绑 / 去掉自愈 / 破坏 depth。 ## 部署验证 - 内核 install -m 0755 原子替换;qq 用 `hmapdev build` 重编 hmap, 经内核接口 `plugin_install(path=…, overwrite=true)` 装(reinstalled, config_kept=true),再 `plgreload` 生效(pid 2643323 新进程) - 重载后连调 5 次 qq_get_friends:**重复申请 stage 锁 0 条** (部署前那份日志 94 条),stage handler error 0 条 - 权限门确认在执行:5 次调用全部返回真实好友数据,非拒绝 - panic/fatal 0、39 插件、graph.db integrity ok - 快照 /home/newqqagent/memory/pre-qq-rebuild-20261007-233032/ Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -38,19 +38,40 @@ type stageLock struct {
|
||||
ownerMu sync.Mutex
|
||||
owner string // 当前持锁的插件名,空表示未持有
|
||||
held bool
|
||||
// depth 是**重入深度**:同一插件嵌套进入同一临界区的次数。
|
||||
//
|
||||
// 为什么需要它(2026-10-07 修):见 Acquire 的注释 —— 同一插件
|
||||
// 可在同一 stage 注册多个 handler(内核 RegisterStageFor 用 append),
|
||||
// 它们并发扇出各发一次 stage.lock,第二个进来时就是同插件重入。
|
||||
depth int
|
||||
}
|
||||
|
||||
func newStageLock() *stageLock { return &stageLock{} }
|
||||
|
||||
// Acquire 为 plugin 申请写锁,带超时。
|
||||
//
|
||||
// 同一插件重复 Acquire 会死锁(stage handler 不应嵌套加锁),
|
||||
// 故显式拒绝并返回错误——比让插件挂死 30s 更容易排查。
|
||||
// # 同插件重入:直接通过(2026-10-07 改)
|
||||
//
|
||||
// 三个版本的设计与代价(都实测过):
|
||||
//
|
||||
// 1. 重入即报错 —— 生产 93 条 qq 报错,第二个 handler 永不执行
|
||||
// (对 qq 而言那是权限门失效)。
|
||||
// 2. 重入即等待 —— 同进程重入是自己等自己,30s 超时死锁。**更糟**:
|
||||
// 我先实现了这版,跑判据立刻挂 30s,教训是「等待」不是重入的正解。
|
||||
// 3. 重入即通过(当前)—— 可重入锁语义。调用方是「插件的一个 stage
|
||||
// handler」,而内核 RegisterStageFor 用 append:**同一插件可以在同一
|
||||
// stage 注册多个 handler**,它们并发扇出、各发一次 stage.lock。
|
||||
// 同一进程内第二个进来时已在临界区内(同一进程只能串行跑),
|
||||
// 本就不该再取互斥量;Release 到 depth 0 才真正释放。
|
||||
//
|
||||
// 不同插件的互斥语义**完全不变**:仍需排队等待。
|
||||
func (l *stageLock) Acquire(plugin string) error {
|
||||
// 可重入:已持有者直接进临界区(不取 mu、不排队)
|
||||
l.ownerMu.Lock()
|
||||
if l.held && l.owner == plugin {
|
||||
l.depth++
|
||||
l.ownerMu.Unlock()
|
||||
return fmt.Errorf("proc: 插件 %s 重复申请 stage 锁(handler 内不应嵌套加锁)", plugin)
|
||||
return nil
|
||||
}
|
||||
l.ownerMu.Unlock()
|
||||
|
||||
@ -65,6 +86,7 @@ func (l *stageLock) Acquire(plugin string) error {
|
||||
l.ownerMu.Lock()
|
||||
l.owner = plugin
|
||||
l.held = true
|
||||
l.depth = 1
|
||||
l.ownerMu.Unlock()
|
||||
return nil
|
||||
case <-time.After(lockWaitTimeout):
|
||||
@ -79,11 +101,15 @@ func (l *stageLock) Acquire(plugin string) error {
|
||||
l.mu.Unlock()
|
||||
}
|
||||
}()
|
||||
return fmt.Errorf("proc: 插件 %s 申请 stage 锁超时(%s)", plugin, lockWaitTimeout)
|
||||
// ★ 文案带持锁方:排查时要能直接知道在等谁。
|
||||
return fmt.Errorf("proc: 插件 %s 申请 stage 锁超时(%s,持锁方=%q)",
|
||||
plugin, lockWaitTimeout, l.Owner())
|
||||
}
|
||||
}
|
||||
|
||||
// Release 释放写锁。非持锁者调用返回错误(防止串扰)。
|
||||
//
|
||||
// 可重入:depth 减到 0 才真正释放(见 stageLock.depth)。
|
||||
func (l *stageLock) Release(plugin string) error {
|
||||
l.ownerMu.Lock()
|
||||
if !l.held {
|
||||
@ -95,8 +121,16 @@ func (l *stageLock) Release(plugin string) error {
|
||||
l.ownerMu.Unlock()
|
||||
return fmt.Errorf("proc: 插件 %s 试图释放 %s 持有的 stage 锁", plugin, owner)
|
||||
}
|
||||
l.depth--
|
||||
if l.depth > 0 {
|
||||
// 仍在嵌套层:锁继续归该插件,但**不释放 mu**
|
||||
// ——否则外层还在临界区里就被别人抢走。
|
||||
l.ownerMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
l.owner = ""
|
||||
l.held = false
|
||||
l.depth = 0
|
||||
l.ownerMu.Unlock()
|
||||
l.mu.Unlock()
|
||||
return nil
|
||||
@ -115,6 +149,7 @@ func (l *stageLock) ForceRelease(plugin string) bool {
|
||||
}
|
||||
l.owner = ""
|
||||
l.held = false
|
||||
l.depth = 0
|
||||
l.ownerMu.Unlock()
|
||||
l.mu.Unlock()
|
||||
return true
|
||||
|
||||
Reference in New Issue
Block a user