mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-15 12:15:16 +00:00
## 生产现象 本机 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>
211 lines
5.5 KiB
Go
211 lines
5.5 KiB
Go
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)
|
||
}
|
||
}
|