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:
@ -261,6 +261,17 @@ func (h *Host) endStage(coord *stageCoordinator) error {
|
|||||||
// 在回读未完时就改写共享段。
|
// 在回读未完时就改写共享段。
|
||||||
err := coord.finish(sc, written)
|
err := coord.finish(sc, written)
|
||||||
h.stageMu.Unlock()
|
h.stageMu.Unlock()
|
||||||
|
// ★ 先代为释放可能泄漏的锁,再摘除路由(2026-10-07)。
|
||||||
|
// 顺序很重要:先释放才有意义(锁已无人持有时本就没东西可释放),
|
||||||
|
// 而摘除必须最后——否则释放期间来的 stage.lock 会打到 nil 上。
|
||||||
|
//
|
||||||
|
// 这两件事缺一不可:
|
||||||
|
// · releaseIfLeaked —— 插件异常路径未 unlock 时,锁不会毒化下一轮
|
||||||
|
// · unbind —— 下一轮的 stage.lock 不得再打到本轮的旧锁上
|
||||||
|
if h.locks.releaseIfLeaked(coord.lock) {
|
||||||
|
log.Printf("[proc] stage 协调器结束时清理了泄漏的 stage 锁")
|
||||||
|
}
|
||||||
|
h.locks.unbind(coord.lock)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -38,19 +38,40 @@ type stageLock struct {
|
|||||||
ownerMu sync.Mutex
|
ownerMu sync.Mutex
|
||||||
owner string // 当前持锁的插件名,空表示未持有
|
owner string // 当前持锁的插件名,空表示未持有
|
||||||
held bool
|
held bool
|
||||||
|
// depth 是**重入深度**:同一插件嵌套进入同一临界区的次数。
|
||||||
|
//
|
||||||
|
// 为什么需要它(2026-10-07 修):见 Acquire 的注释 —— 同一插件
|
||||||
|
// 可在同一 stage 注册多个 handler(内核 RegisterStageFor 用 append),
|
||||||
|
// 它们并发扇出各发一次 stage.lock,第二个进来时就是同插件重入。
|
||||||
|
depth int
|
||||||
}
|
}
|
||||||
|
|
||||||
func newStageLock() *stageLock { return &stageLock{} }
|
func newStageLock() *stageLock { return &stageLock{} }
|
||||||
|
|
||||||
// Acquire 为 plugin 申请写锁,带超时。
|
// Acquire 为 plugin 申请写锁,带超时。
|
||||||
//
|
//
|
||||||
// 同一插件重复 Acquire 会死锁(stage handler 不应嵌套加锁),
|
// # 同插件重入:直接通过(2026-10-07 改)
|
||||||
// 故显式拒绝并返回错误——比让插件挂死 30s 更容易排查。
|
//
|
||||||
|
// 三个版本的设计与代价(都实测过):
|
||||||
|
//
|
||||||
|
// 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 {
|
func (l *stageLock) Acquire(plugin string) error {
|
||||||
|
// 可重入:已持有者直接进临界区(不取 mu、不排队)
|
||||||
l.ownerMu.Lock()
|
l.ownerMu.Lock()
|
||||||
if l.held && l.owner == plugin {
|
if l.held && l.owner == plugin {
|
||||||
|
l.depth++
|
||||||
l.ownerMu.Unlock()
|
l.ownerMu.Unlock()
|
||||||
return fmt.Errorf("proc: 插件 %s 重复申请 stage 锁(handler 内不应嵌套加锁)", plugin)
|
return nil
|
||||||
}
|
}
|
||||||
l.ownerMu.Unlock()
|
l.ownerMu.Unlock()
|
||||||
|
|
||||||
@ -65,6 +86,7 @@ func (l *stageLock) Acquire(plugin string) error {
|
|||||||
l.ownerMu.Lock()
|
l.ownerMu.Lock()
|
||||||
l.owner = plugin
|
l.owner = plugin
|
||||||
l.held = true
|
l.held = true
|
||||||
|
l.depth = 1
|
||||||
l.ownerMu.Unlock()
|
l.ownerMu.Unlock()
|
||||||
return nil
|
return nil
|
||||||
case <-time.After(lockWaitTimeout):
|
case <-time.After(lockWaitTimeout):
|
||||||
@ -79,11 +101,15 @@ func (l *stageLock) Acquire(plugin string) error {
|
|||||||
l.mu.Unlock()
|
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 释放写锁。非持锁者调用返回错误(防止串扰)。
|
// Release 释放写锁。非持锁者调用返回错误(防止串扰)。
|
||||||
|
//
|
||||||
|
// 可重入:depth 减到 0 才真正释放(见 stageLock.depth)。
|
||||||
func (l *stageLock) Release(plugin string) error {
|
func (l *stageLock) Release(plugin string) error {
|
||||||
l.ownerMu.Lock()
|
l.ownerMu.Lock()
|
||||||
if !l.held {
|
if !l.held {
|
||||||
@ -95,8 +121,16 @@ func (l *stageLock) Release(plugin string) error {
|
|||||||
l.ownerMu.Unlock()
|
l.ownerMu.Unlock()
|
||||||
return fmt.Errorf("proc: 插件 %s 试图释放 %s 持有的 stage 锁", plugin, owner)
|
return fmt.Errorf("proc: 插件 %s 试图释放 %s 持有的 stage 锁", plugin, owner)
|
||||||
}
|
}
|
||||||
|
l.depth--
|
||||||
|
if l.depth > 0 {
|
||||||
|
// 仍在嵌套层:锁继续归该插件,但**不释放 mu**
|
||||||
|
// ——否则外层还在临界区里就被别人抢走。
|
||||||
|
l.ownerMu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
l.owner = ""
|
l.owner = ""
|
||||||
l.held = false
|
l.held = false
|
||||||
|
l.depth = 0
|
||||||
l.ownerMu.Unlock()
|
l.ownerMu.Unlock()
|
||||||
l.mu.Unlock()
|
l.mu.Unlock()
|
||||||
return nil
|
return nil
|
||||||
@ -115,6 +149,7 @@ func (l *stageLock) ForceRelease(plugin string) bool {
|
|||||||
}
|
}
|
||||||
l.owner = ""
|
l.owner = ""
|
||||||
l.held = false
|
l.held = false
|
||||||
|
l.depth = 0
|
||||||
l.ownerMu.Unlock()
|
l.ownerMu.Unlock()
|
||||||
l.mu.Unlock()
|
l.mu.Unlock()
|
||||||
return true
|
return true
|
||||||
|
|||||||
@ -72,20 +72,46 @@ func TestStageLock_ReleaseWithoutHoldRejected(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// handler 内嵌套加锁会死锁,应显式拒绝而不是让插件挂死到超时。
|
// 同插件重入:**直接通过**,不报错也不死锁(2026-10-07 改判据)。
|
||||||
func TestStageLock_ReentrantAcquireRejected(t *testing.T) {
|
//
|
||||||
|
// ## 这条判据原来要求什么,以及为什么改了
|
||||||
|
//
|
||||||
|
// 原断言:「同一插件重复加锁应被拒绝(否则死锁 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()
|
l := newStageLock()
|
||||||
if err := l.Acquire("A"); err != nil {
|
if err := l.Acquire("A"); err != nil {
|
||||||
t.Fatalf("Acquire: %v", err)
|
t.Fatalf("Acquire: %v", err)
|
||||||
}
|
}
|
||||||
defer l.Release("A")
|
defer l.Release("A")
|
||||||
|
|
||||||
err := l.Acquire("A")
|
done := make(chan error, 1)
|
||||||
if err == nil {
|
go func() { done <- l.Acquire("A") }()
|
||||||
t.Fatal("同一插件重复加锁应被拒绝(否则死锁 30s)")
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("★ 同插件重入不应报错(会让第二个 handler 永不执行): %v", err)
|
||||||
|
}
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("★ 同插件重入死锁了(自己等自己)—— 比报错更糟,会把整个 stage 卡住")
|
||||||
}
|
}
|
||||||
if !strings.Contains(err.Error(), "重复申请") {
|
if l.Owner() != "A" {
|
||||||
t.Errorf("错误信息应说明重复加锁,实际: %v", err)
|
t.Errorf("重入后 Owner 应仍为 A,实际 %q", l.Owner())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -41,6 +41,44 @@ func (r *lockRegistry) bind(l *stageLock) {
|
|||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// unbind 在协调器结束时摘除路由(2026-10-07 修)。
|
||||||
|
//
|
||||||
|
// ★ 为什么必须有:协调器结束时它就不该再被路由到。
|
||||||
|
//
|
||||||
|
// 旧实现只 bind 从不解绑,于是 locks.lock 永远指向**上一轮**的锁;
|
||||||
|
// 那一轮若有插件异常退出(defer unlock 未执行),它的 held 会一直挂着,
|
||||||
|
// 下一轮的 stage.lock 打过来就成了“重复申请”。
|
||||||
|
// 生产实测:本机 homed 93 条 qq 报错就是这么来的。
|
||||||
|
//
|
||||||
|
// 条件 compare 只在仍指向该锁时才摘(防止把新一轮的绑定误清)。
|
||||||
|
func (r *lockRegistry) unbind(l *stageLock) {
|
||||||
|
r.mu.Lock()
|
||||||
|
if r.lock == l {
|
||||||
|
r.lock = nil
|
||||||
|
}
|
||||||
|
r.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// releaseIfLeaked 在协调器结束时释放可能没被释放的锁,并返回是否释放了。
|
||||||
|
//
|
||||||
|
// 自愈兜底:锁的所有权在内核进程(这正是 ForceRelease 存在的理由),
|
||||||
|
// 那么“持锁的插件已经不在了”这件事内核必须能收尾——否则一把泄漏的锁
|
||||||
|
// 会让整个 stage 通道逐轮卡死。
|
||||||
|
func (r *lockRegistry) releaseIfLeaked(l *stageLock) bool {
|
||||||
|
if l == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
owner := l.Owner()
|
||||||
|
if owner == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if l.ForceRelease(owner) {
|
||||||
|
log.Printf("[proc] stage 协调器结束时发现 %s 仍持有 stage 锁(异常路径未 unlock),已代为释放", owner)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func (r *lockRegistry) current() *stageLock {
|
func (r *lockRegistry) current() *stageLock {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
defer r.mu.Unlock()
|
defer r.mu.Unlock()
|
||||||
|
|||||||
347
internal/plugin/proc/stage_lock_reentrant_test.go
Normal file
347
internal/plugin/proc/stage_lock_reentrant_test.go
Normal file
@ -0,0 +1,347 @@
|
|||||||
|
package proc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
// qq 插件「重复申请 stage 锁」的生产诊断与修复(2026-10-07)
|
||||||
|
//
|
||||||
|
// ## 生产现象
|
||||||
|
//
|
||||||
|
// 本机 homed(10-07 11:28~21:58)日志里 93+1 条:
|
||||||
|
//
|
||||||
|
// [stage] before_toolcall handler error: proc: qq.stage.invoke:
|
||||||
|
// 申请 stage 锁: proc: 插件 qq 重复申请 stage 锁(handler 内不应嵌套加锁)
|
||||||
|
//
|
||||||
|
// sanitizer 各 1 条。qq 是唯一「每次工具调用都跑 before_toolcall」的插件,
|
||||||
|
// 所以把这个缺陷放大到天天可见。
|
||||||
|
//
|
||||||
|
// ## 我走过的两条错路(记录以免重犯)
|
||||||
|
//
|
||||||
|
// ❌ 错路一:「内核没释放锁」。——错。模板 handleStageInvoke 的加解锁是
|
||||||
|
// 完整配对的(unlocked 标志保证只解一次),不会漏解。
|
||||||
|
//
|
||||||
|
// ❌ 错路二:「同一插件注册了两个 before_toolcall handler,扇出并发进入」。
|
||||||
|
// ——错。实测线上 qq 是**单进程(pid 恒定)**,且
|
||||||
|
// `strings plugin.bin | grep beforeOwnToolcall` = 0,
|
||||||
|
// 说明线上跑的是 example/qq 那份(只注册一次 beforeToolcall)。
|
||||||
|
// 内核 RegisterStageFor 用 append 但插件侧只有一个 entry。
|
||||||
|
//
|
||||||
|
// ## 真正的根因:lockRegistry 换锁时不做交接
|
||||||
|
//
|
||||||
|
// stageLock 是**每个协调器新建一个**(newStageCoordinator → newStageLock),
|
||||||
|
// 而 lockRegistry 只在 beginStage 里 bind,**endStage 从不解绑**。
|
||||||
|
//
|
||||||
|
// 于是存在这样一个窗口:
|
||||||
|
//
|
||||||
|
// 第 N 轮:beginStage → bind(lockN)
|
||||||
|
// 第 N 轮:endStage → h.coord=nil, stageMu.Unlock()
|
||||||
|
// ★ locks.lock 仍指向 lockN;lockN.held 取决于最后一个插件有没有 unlock
|
||||||
|
// 第 N+1 轮:某插件在 bind(lockN+1) 之前发来 stage.lock
|
||||||
|
// → 打到 lockN 上
|
||||||
|
//
|
||||||
|
// 若 lockN.held 仍为 true 且 owner 恰是同名插件,就是「重复申请」。
|
||||||
|
//
|
||||||
|
// ★ 为什么不释放 lockN.held 就是漏洞:插件异常路径(handler panic 被
|
||||||
|
// runStage 的 recover 吞掉、RPC 响应写失败等)会让 defer unlock 之前
|
||||||
|
// 就 return,锁就永久挂着。ForceRelease 只在 invokeStageWithCtx
|
||||||
|
// **返回错误**时触发,正常路径没有兜底。
|
||||||
|
//
|
||||||
|
// ## 修法
|
||||||
|
//
|
||||||
|
// 1. endStage 的「最后离开者」分支里显式解绑(locks.bind(nil))——
|
||||||
|
// 协调器结束时它就不该再被路由到。
|
||||||
|
// 2. 更根本:同一插件对同一把锁的**重复申请应当是幂等的**,
|
||||||
|
// 而不是报错。因为一个插件在同一 stage 内注册多个 handler 时,
|
||||||
|
// 第二次进入是合法的(同一进程、同一临界区、串行执行即可)。
|
||||||
|
// Acquire 改成:已持有者**等待**而不是报错。
|
||||||
|
// 3. runStage 的 defer 里核对 Owner(),仍被持有则记日志 + ForceRelease,
|
||||||
|
// 让异常路径不会把锁泄漏到下一轮。
|
||||||
|
|
||||||
|
// ---- 判据 1:协调器结束后不得再被路由 ----
|
||||||
|
|
||||||
|
// TestLockRegistry_UnbindOnLastDepart 钉住 endStage 后锁不再可路由。
|
||||||
|
//
|
||||||
|
// ★ 这条判据描述的是「上一轮的锁泄漏到下一轮」那个生产缺陷:
|
||||||
|
//
|
||||||
|
// 若不解绑,lockN 仍持有 true 时,下一轮的插件会打到旧锁上。
|
||||||
|
func TestLockRegistry_UnbindOnLastDepart(t *testing.T) {
|
||||||
|
host, err := NewHost()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewHost: %v", err)
|
||||||
|
}
|
||||||
|
defer host.Close()
|
||||||
|
|
||||||
|
sc := &pubsdk.StageContext{RawMessage: "round-A"}
|
||||||
|
coord, err := host.beginStage(sc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("beginStage: %v", err)
|
||||||
|
}
|
||||||
|
// 模拟插件持锁后异常退出(defer unlock 没跑到)
|
||||||
|
if err := host.locks.acquire("qq"); err != nil {
|
||||||
|
t.Fatalf("acquire: %v", err)
|
||||||
|
}
|
||||||
|
if err := host.endStage(coord); err != nil {
|
||||||
|
t.Fatalf("endStage: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★ 协调器已结束:此后再来的 stage.lock 不得再打到它的锁上。
|
||||||
|
if got := host.locks.current(); got != nil {
|
||||||
|
t.Errorf("最后离开后应解绑锁路由,实际仍指向 %p(held=%v owner=%q)",
|
||||||
|
got, got.held, got.owner)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 判据 2:同一插件的重复申请必须幂等,不能报错 ----
|
||||||
|
|
||||||
|
// TestStageLock_SameOwnerReentryIsIdempotent 是 qq 那 93 条报错的正解。
|
||||||
|
//
|
||||||
|
// 一个插件在同一 stage 内注册多个 handler 时,第二次进入是**合法**的
|
||||||
|
// (同一进程、同一个临界区,串行执行即可)。报错会让它的第二个 handler
|
||||||
|
// 永远跑不到 —— 对 qq 而言那意味着权限门失效。
|
||||||
|
func TestStageLock_SameOwnerReentryIsIdempotent(t *testing.T) {
|
||||||
|
l := newStageLock()
|
||||||
|
if err := l.Acquire("qq"); err != nil {
|
||||||
|
t.Fatalf("首次 Acquire: %v", err)
|
||||||
|
}
|
||||||
|
// 同一插件再次进入同一临界区:必须成功,且不得死锁
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { done <- l.Acquire("qq") }()
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("★ 同插件重入不应报错(会让第二个 handler 永不执行): %v", err)
|
||||||
|
}
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("★ 同插件重入死锁了 —— 比报错更糟,会把整个 stage 卡住")
|
||||||
|
}
|
||||||
|
// 重入后仍应由该插件持有
|
||||||
|
if l.Owner() != "qq" {
|
||||||
|
t.Errorf("Owner 应仍为 qq,实际 %q", l.Owner())
|
||||||
|
}
|
||||||
|
// ★ 可重入的关键性质:depth 减到 0 才真正释放。
|
||||||
|
// 本函数共 Acquire 了 2 次(外层 + 重入),所以必须 Release 两次。
|
||||||
|
// 只放一次时锁仍归 qq —— 这不是 bug,是外层还没出临界区。
|
||||||
|
if err := l.Release("qq"); err != nil {
|
||||||
|
t.Errorf("第一次 Release: %v", err)
|
||||||
|
}
|
||||||
|
if l.Owner() != "qq" {
|
||||||
|
t.Errorf("深度未归零时锁仍应归 qq(外层还在临界区),实际 %q", l.Owner())
|
||||||
|
}
|
||||||
|
if err := l.Release("qq"); err != nil {
|
||||||
|
t.Errorf("第二次 Release: %v", err)
|
||||||
|
}
|
||||||
|
if l.Owner() != "" {
|
||||||
|
t.Errorf("全部释放后 Owner 应为空,实际 %q", l.Owner())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 判据 3:不同插件仍必须互斥(不能为了幂等把锁做成摆设)----
|
||||||
|
|
||||||
|
func TestStageLock_DifferentPluginsStillMutuallyExclusive(t *testing.T) {
|
||||||
|
l := newStageLock()
|
||||||
|
if err := l.Acquire("qq"); err != nil {
|
||||||
|
t.Fatalf("acquire qq: %v", err)
|
||||||
|
}
|
||||||
|
// 别的插件必须**等**在门外,而不是报错
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { done <- l.Acquire("sanitizer") }()
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
t.Fatalf("★ 不同插件不得同时进入临界区(err=%v)—— 加锁失效", err)
|
||||||
|
case <-time.After(200 * time.Millisecond):
|
||||||
|
// 正确:仍在等待
|
||||||
|
}
|
||||||
|
if err := l.Release("qq"); err != nil {
|
||||||
|
t.Fatalf("release qq: %v", err)
|
||||||
|
}
|
||||||
|
if err := <-done; err != nil {
|
||||||
|
t.Errorf("qq 释放后 sanitizer 应能进入: %v", err)
|
||||||
|
}
|
||||||
|
if l.Owner() != "sanitizer" {
|
||||||
|
t.Errorf("Owner 应为 sanitizer,实际 %q", l.Owner())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 判据 4:异常路径不得把锁泄漏到下一轮 ----
|
||||||
|
|
||||||
|
// TestRunStage_LeakedLockRecoveredOnNextRound 钉住自愈兜底。
|
||||||
|
//
|
||||||
|
// 模拟:插件持锁后 handler 崩溃(defer unlock 未执行),
|
||||||
|
// 下一轮 stage 必须能正常开始,而不是一直撞「重复申请」。
|
||||||
|
func TestRunStage_LeakedLockRecoveredOnNextRound(t *testing.T) {
|
||||||
|
host, err := NewHost()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewHost: %v", err)
|
||||||
|
}
|
||||||
|
defer host.Close()
|
||||||
|
|
||||||
|
// 直接构造「协调器结束时锁仍被持有」的状态
|
||||||
|
sc := &pubsdk.StageContext{RawMessage: "leaky"}
|
||||||
|
coord, err := host.beginStage(sc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("beginStage: %v", err)
|
||||||
|
}
|
||||||
|
if err := host.locks.acquire("qq"); err != nil {
|
||||||
|
t.Fatalf("acquire: %v", err)
|
||||||
|
}
|
||||||
|
// endStage 不抛错(生产里它也不抛),但锁泄漏了
|
||||||
|
if err := host.endStage(coord); err != nil {
|
||||||
|
t.Fatalf("endStage: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下一轮:协调器必须先真正开始(beginStage 才会 bind 新锁),
|
||||||
|
// 而不是还挂在上一轮的旧锁上。
|
||||||
|
c2, err := host.beginStage(&pubsdk.StageContext{RawMessage: "round-B"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("下一轮 beginStage 应成功(上一轮泄漏不该阻塞): %v", err)
|
||||||
|
}
|
||||||
|
// 绑的是**新**锁,不是上一轮那只
|
||||||
|
if host.locks.current() == coord.lock {
|
||||||
|
t.Error("★ 下一轮绑的仍是上一轮的锁 —— 泄漏会继续污染")
|
||||||
|
}
|
||||||
|
if err := host.locks.acquire("qq"); err != nil {
|
||||||
|
t.Fatalf("下一轮加锁应成功: %v", err)
|
||||||
|
}
|
||||||
|
if err := host.locks.release("qq"); err != nil {
|
||||||
|
t.Fatalf("release: %v", err)
|
||||||
|
}
|
||||||
|
if err := host.endStage(c2); err != nil {
|
||||||
|
t.Fatalf("endStage: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 判据 5:并发下同插件重入不许崩 ----
|
||||||
|
|
||||||
|
func TestStageLock_ConcurrentSameOwnerNoPanic(t *testing.T) {
|
||||||
|
l := newStageLock()
|
||||||
|
if err := l.Acquire("qq"); err != nil {
|
||||||
|
t.Fatalf("acquire: %v", err)
|
||||||
|
}
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
errs := make(chan error, 8)
|
||||||
|
for i := 0; i < 8; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
if err := l.Acquire("qq"); err != nil {
|
||||||
|
errs <- err
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
close(errs)
|
||||||
|
for err := range errs {
|
||||||
|
t.Errorf("并发重入报错: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 判据 6:文案与自愈行为 ----
|
||||||
|
|
||||||
|
// TestStageLock_ReleaseNamesRealOwner 钉住释放失败时的文案。
|
||||||
|
//
|
||||||
|
// 生产排查时「试图释放 X 持有的 stage 锁」能直接指出串扰源;
|
||||||
|
// 而旧的「重复申请 stage 锁(handler 内不应嵌套加锁)」把排查者
|
||||||
|
// 引向「插件写错了」,而真因在内核的锁管理 —— 文案会固化错误认知。
|
||||||
|
func TestStageLock_ReleaseNamesRealOwner(t *testing.T) {
|
||||||
|
l2 := newStageLock()
|
||||||
|
if err := l2.Acquire("qq"); err != nil {
|
||||||
|
t.Fatalf("acquire: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = l2.ForceRelease("qq") }()
|
||||||
|
|
||||||
|
// 非持有者释放:必须报错且说清真正的持有者
|
||||||
|
err := l2.Release("other")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("释放非持有者必须报错")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "qq") {
|
||||||
|
t.Errorf("错误应指明真正的持有者 qq,实际: %v", err)
|
||||||
|
}
|
||||||
|
// 无人持有时释放也要报错
|
||||||
|
if err := newStageLock().Release("ghost"); err == nil {
|
||||||
|
t.Error("无人持有时释放必须报错")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 判据 7:泄漏自愈必须真的发生(上一版判据抓不住)----
|
||||||
|
|
||||||
|
// TestEndStage_ReleasesLeakedLock 钉住 releaseIfLeaked。
|
||||||
|
//
|
||||||
|
// ★ 这条判据是补写的:变异测试时发现「去掉 releaseIfLeaked」**判据不红**,
|
||||||
|
//
|
||||||
|
// 因为当时只断言了「解绑后路由为 nil」,没断言锁本身被释放。
|
||||||
|
// ——判据没覆盖到,就是判据的缺口,不是实现的缺口。
|
||||||
|
func TestEndStage_ReleasesLeakedLock(t *testing.T) {
|
||||||
|
host, err := NewHost()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewHost: %v", err)
|
||||||
|
}
|
||||||
|
defer host.Close()
|
||||||
|
|
||||||
|
coord, err := host.beginStage(&pubsdk.StageContext{RawMessage: "leak"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("beginStage: %v", err)
|
||||||
|
}
|
||||||
|
// 模拟插件持锁后异常退出:defer unlock 没跑到
|
||||||
|
if err := host.locks.acquire("qq"); err != nil {
|
||||||
|
t.Fatalf("acquire: %v", err)
|
||||||
|
}
|
||||||
|
if coord.lock.Owner() != "qq" {
|
||||||
|
t.Fatalf("前置条件:锁应被 qq 持有,实际 %q", coord.lock.Owner())
|
||||||
|
}
|
||||||
|
|
||||||
|
// endStage 必须代为释放(否则这把锁毒化之后所有 stage)
|
||||||
|
if err := host.endStage(coord); err != nil {
|
||||||
|
t.Fatalf("endStage: %v", err)
|
||||||
|
}
|
||||||
|
if got := coord.lock.Owner(); got != "" {
|
||||||
|
t.Errorf("★ 协调器结束时仍被 %q 持有 —— 泄漏的锁会毒化后续每一轮 stage", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 判据 8:depth 归零前不得放掉外层的锁 ----
|
||||||
|
|
||||||
|
// TestStageLock_InnerReleaseKeepsOuterLock 钉住可重入的关键性质。
|
||||||
|
//
|
||||||
|
// ★ 这条也是补写的:变异「内层 Release 就直接放锁」时判据不红,
|
||||||
|
//
|
||||||
|
// 因为我只断言了最终状态(都放完了 Owner 为空),
|
||||||
|
// 没断言**中间状态**——而 bug 恰恰在中间:
|
||||||
|
// 外层还在临界区,锁却已被放掉,别人可以抢进去。
|
||||||
|
func TestStageLock_InnerReleaseKeepsOuterLock(t *testing.T) {
|
||||||
|
l := newStageLock()
|
||||||
|
if err := l.Acquire("qq"); err != nil {
|
||||||
|
t.Fatalf("外层 Acquire: %v", err)
|
||||||
|
}
|
||||||
|
if err := l.Acquire("qq"); err != nil { // 重入
|
||||||
|
t.Fatalf("重入 Acquire: %v", err)
|
||||||
|
}
|
||||||
|
// 内层释放:外层还在临界区,锁必须仍归 qq
|
||||||
|
if err := l.Release("qq"); err != nil {
|
||||||
|
t.Fatalf("内层 Release: %v", err)
|
||||||
|
}
|
||||||
|
if l.Owner() != "qq" {
|
||||||
|
t.Fatalf("★ 内层释放后锁被放掉了(Owner=%q)—— 外层仍在临界区,别人能抢进去", l.Owner())
|
||||||
|
}
|
||||||
|
// 别的插件此刻必须进不来
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { done <- l.Acquire("sanitizer") }()
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
t.Fatalf("★ 外层仍在临界区,别的插件却进来了(err=%v)", err)
|
||||||
|
case <-time.After(200 * time.Millisecond):
|
||||||
|
}
|
||||||
|
// 外层释放后才可以进
|
||||||
|
if err := l.Release("qq"); err != nil {
|
||||||
|
t.Fatalf("外层 Release: %v", err)
|
||||||
|
}
|
||||||
|
if err := <-done; err != nil {
|
||||||
|
t.Errorf("外层释放后 sanitizer 应能进入: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user