Files
HomeAgent/internal/plugin/proc/lock_test.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

211 lines
5.5 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 (
"strings"
"sync"
"testing"
"time"
)
// 锁仲裁回归内核(§3.7 已裁定)的行为验证,含实验 9 的崩溃自愈机制。
func TestStageLock_MutualExclusion(t *testing.T) {
l := newStageLock()
if err := l.Acquire("A"); err != nil {
t.Fatalf("A 应能获得锁: %v", err)
}
if l.Owner() != "A" {
t.Errorf("Owner 应为 A,实际 %q", l.Owner())
}
// B 在 A 持锁期间不得进入
entered := make(chan struct{})
go func() {
_ = l.Acquire("B")
close(entered)
}()
select {
case <-entered:
t.Fatal("A 持锁期间 B 不应获得锁")
case <-time.After(50 * time.Millisecond):
}
if err := l.Release("A"); err != nil {
t.Fatalf("A 释放失败: %v", err)
}
select {
case <-entered:
case <-time.After(2 * time.Second):
t.Fatal("A 释放后 B 应获得锁")
}
if l.Owner() != "B" {
t.Errorf("Owner 应为 B,实际 %q", l.Owner())
}
_ = l.Release("B")
}
// 非持锁者不得释放他人的锁(防止串扰导致并发正确性被破坏)。
func TestStageLock_ReleaseByNonOwnerRejected(t *testing.T) {
l := newStageLock()
if err := l.Acquire("A"); err != nil {
t.Fatalf("Acquire: %v", err)
}
defer l.Release("A")
err := l.Release("B")
if err == nil {
t.Fatal("非持锁者释放应被拒绝")
}
if !strings.Contains(err.Error(), "试图释放") {
t.Errorf("错误信息应说明串扰,实际: %v", err)
}
if l.Owner() != "A" {
t.Errorf("A 应仍持锁,实际 owner=%q", l.Owner())
}
}
func TestStageLock_ReleaseWithoutHoldRejected(t *testing.T) {
l := newStageLock()
if err := l.Release("A"); err == nil {
t.Fatal("未持锁时释放应报错")
}
}
// 同插件重入:**直接通过**,不报错也不死锁(2026-10-07 改判据)。
//
// ## 这条判据原来要求什么,以及为什么改了
//
// 原断言:「同一插件重复加锁应被拒绝(否则死锁 30s)」。
// 它在 2026-09 的实验 9 里是对的 —— 那时把 Acquire 做成等待,
// 而同进程重入就是自己等自己。
//
// 生产实测(本机 homed 10-07,93 条)证明「拒绝」本身是错的:
//
// [stage] before_toolcall handler error: proc: qq.stage.invoke:
// 申请 stage 锁: proc: 插件 qq 重复申请 stage 锁(handler 内不应嵌套加锁)
//
// qq 的 before_toolcall 是它的**权限门**;每次工具调用都跑。
// 被拒绝 ⇒ handler 永远跑不到 ⇒ 权限门失效。
//
// 也不能改成「等待」:我先实现了那版,跑判据直接挂 30s
// (同进程重入 = 自己等自己)。⇒ 唯一正解是**可重入**。
//
// 断言仍要守住的那件事:**别的插件必须仍在门外互斥**
// (见 TestStageLock_DifferentPluginsStillMutuallyExclusive)。
func TestStageLock_ReentrantAcquirePassesThrough(t *testing.T) {
l := newStageLock()
if err := l.Acquire("A"); err != nil {
t.Fatalf("Acquire: %v", err)
}
defer l.Release("A")
done := make(chan error, 1)
go func() { done <- l.Acquire("A") }()
select {
case err := <-done:
if err != nil {
t.Fatalf("★ 同插件重入不应报错(会让第二个 handler 永不执行): %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("★ 同插件重入死锁了(自己等自己)—— 比报错更糟,会把整个 stage 卡住")
}
if l.Owner() != "A" {
t.Errorf("重入后 Owner 应仍为 A,实际 %q", l.Owner())
}
}
// 实验 9 的核心:持锁进程崩溃后内核代为释放,后续插件不死锁。
// 这条彻底排除了 robust pthread_mutex 的必要性 —— 整个架构零 cgo。
func TestStageLock_ForceReleaseOnPluginCrash(t *testing.T) {
l := newStageLock()
// 插件 X 拿锁后"崩溃"(不调用 Release)
if err := l.Acquire("X"); err != nil {
t.Fatalf("X Acquire: %v", err)
}
if !l.ForceRelease("X") {
t.Fatal("内核应能强制释放崩溃插件持有的锁")
}
if l.Owner() != "" {
t.Errorf("强制释放后应无持有者,实际 %q", l.Owner())
}
// 插件 Y 随后必须能正常拿到锁(无死锁)
done := make(chan error, 1)
go func() { done <- l.Acquire("Y") }()
select {
case err := <-done:
if err != nil {
t.Fatalf("Y 应能获得锁: %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("X 崩溃后 Y 无法获得锁 —— 出现死锁")
}
if err := l.Release("Y"); err != nil {
t.Fatalf("Y 释放失败: %v", err)
}
}
// ForceRelease 对非持有者/未持锁应为 no-op,不能误放他人的锁。
func TestStageLock_ForceReleaseIsTargeted(t *testing.T) {
l := newStageLock()
if err := l.Acquire("A"); err != nil {
t.Fatalf("Acquire: %v", err)
}
defer l.Release("A")
if l.ForceRelease("B") {
t.Error("强制释放不该动 A 持有的锁")
}
if l.Owner() != "A" {
t.Errorf("A 应仍持锁,实际 %q", l.Owner())
}
}
// 高并发下锁的串行化保证:临界区不重叠。
func TestStageLock_SerializesCriticalSection(t *testing.T) {
l := newStageLock()
var (
mu sync.Mutex
inside int
maxSeen int
)
const workers = 8
const iters = 50
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
name := string(rune('A' + id))
for j := 0; j < iters; j++ {
if err := l.Acquire(name); err != nil {
t.Errorf("Acquire: %v", err)
return
}
mu.Lock()
inside++
if inside > maxSeen {
maxSeen = inside
}
mu.Unlock()
mu.Lock()
inside--
mu.Unlock()
if err := l.Release(name); err != nil {
t.Errorf("Release: %v", err)
return
}
}
}(i)
}
wg.Wait()
if maxSeen > 1 {
t.Fatalf("临界区出现并发:同时 %d 个持有者", maxSeen)
}
}