Files
HomeAgent/internal/plugin/proc/lock.go
JianFeeeee 476e1b1296 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>
2026-10-08 07:56:51 +08:00

167 lines
5.7 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 proc
import (
"fmt"
"sync"
"time"
)
// 跨进程锁:锁仲裁回归内核(§3.7 已裁定,实验 3 + 实验 9 支撑)。
//
// 为什么不用 robust pthread_mutex:
// - PTHREAD_PROCESS_SHARED + ROBUST 属性 Go 标准库无等价物,引入它意味着
// **为了一把锁保留 cgo**——与"C 整体退场"的目标冲突。
// - 锁仲裁回内核后,持锁进程崩溃由 cmd.Wait()/stdio EOF 检测,内核代为释放;
// 实验 9 实测无死锁、**无需 EOWNERDEAD 处理**。
// - 成本:一次 RPC 往返 19.4 µs(实验 3,20000 次测得)。stage handler 的加锁
// 频率很低(每次 stage 一两次,不是每字段一次),微秒级往返可忽略。
//
// 于是整个新架构可做到**完全无 cgo**。
// lockWaitTimeout 是插件申请 stage 锁的最长等待时间。
//
// 取 30s:stage handler 自身受 60s 工具超时约束(toolcall.go),锁等待
// 必须显著短于它,否则超时错误会指向错误的原因。超时返回错误而非
// 静默继续——**持锁失败下改写共享段会破坏并发正确性**。
const lockWaitTimeout = 30 * time.Second
// stageLock 是内核侧为单个 stage 执行持有的互斥体。
//
// 一次 RunStage 对应一个 stageLock 实例:同阶段并发扇出的所有插件
// (含跨进程的)在此排队,语义等价于今日内置插件共享
// *StageContext 的 sync.RWMutex——这正是"保留并发扇出原始设计"
// (§0.2 第 1 条:并发扇出是原始设计,不是缺陷)。
type stageLock struct {
mu sync.Mutex
// ownerMu 保护 owner/held,使 ForceRelease 能安全介入
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 申请写锁,带超时。
//
// # 同插件重入:直接通过(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 nil
}
l.ownerMu.Unlock()
acquired := make(chan struct{})
go func() {
l.mu.Lock()
close(acquired)
}()
select {
case <-acquired:
l.ownerMu.Lock()
l.owner = plugin
l.held = true
l.depth = 1
l.ownerMu.Unlock()
return nil
case <-time.After(lockWaitTimeout):
// 等待超时:上面的 goroutine 可能随后拿到锁,必须让它能释放,
// 否则锁永久泄漏。用一个补偿 goroutine 等它拿到后立刻放掉。
go func() {
<-acquired
l.ownerMu.Lock()
stillFree := !l.held
l.ownerMu.Unlock()
if stillFree {
l.mu.Unlock()
}
}()
// ★ 文案带持锁方:排查时要能直接知道在等谁。
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 {
l.ownerMu.Unlock()
return fmt.Errorf("proc: 插件 %s 释放未持有的 stage 锁", plugin)
}
if l.owner != plugin {
owner := l.owner
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
}
// ForceRelease 在插件进程崩溃/退出时由内核代为释放其持有的锁(实验 9 的自愈机制)。
//
// 返回是否实际释放了锁。**这是"无需 robust mutex"的核心**:
// 锁的所有权在内核进程,插件死亡由 cmd.Wait()/stdio EOF 检测到,
// 内核直接解锁,不存在"持锁者死亡导致全局死锁"。
func (l *stageLock) ForceRelease(plugin string) bool {
l.ownerMu.Lock()
if !l.held || l.owner != plugin {
l.ownerMu.Unlock()
return false
}
l.owner = ""
l.held = false
l.depth = 0
l.ownerMu.Unlock()
l.mu.Unlock()
return true
}
// Owner 返回当前持锁插件名(诊断用)。
func (l *stageLock) Owner() string {
l.ownerMu.Lock()
defer l.ownerMu.Unlock()
if !l.held {
return ""
}
return l.owner
}