From 476e1b1296f9840f8e032ec794c5669b2763dd4a Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Wed, 7 Oct 2026 23:36:19 +0800 Subject: [PATCH] =?UTF-8?q?fix(proc):=20stage=20=E9=94=81=E6=94=B9?= =?UTF-8?q?=E5=8F=AF=E9=87=8D=E5=85=A5=20+=20=E5=8D=8F=E8=B0=83=E5=99=A8?= =?UTF-8?q?=E7=BB=93=E6=9D=9F=E8=A7=A3=E7=BB=91=E4=B8=8E=E8=87=AA=E6=84=88?= =?UTF-8?q?=EF=BC=8C=E8=A7=A3=20qq=E3=80=8C=E9=87=8D=E5=A4=8D=E7=94=B3?= =?UTF-8?q?=E8=AF=B7=20stage=20=E9=94=81=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 生产现象 本机 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) --- internal/plugin/proc/host.go | 11 + internal/plugin/proc/lock.go | 43 ++- internal/plugin/proc/lock_test.go | 40 +- internal/plugin/proc/stage.go | 38 ++ .../plugin/proc/stage_lock_reentrant_test.go | 347 ++++++++++++++++++ 5 files changed, 468 insertions(+), 11 deletions(-) create mode 100644 internal/plugin/proc/stage_lock_reentrant_test.go diff --git a/internal/plugin/proc/host.go b/internal/plugin/proc/host.go index 2d5239e2..12e249f1 100644 --- a/internal/plugin/proc/host.go +++ b/internal/plugin/proc/host.go @@ -261,6 +261,17 @@ func (h *Host) endStage(coord *stageCoordinator) error { // 在回读未完时就改写共享段。 err := coord.finish(sc, written) 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 } diff --git a/internal/plugin/proc/lock.go b/internal/plugin/proc/lock.go index a07d2441..f2b7071e 100644 --- a/internal/plugin/proc/lock.go +++ b/internal/plugin/proc/lock.go @@ -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 diff --git a/internal/plugin/proc/lock_test.go b/internal/plugin/proc/lock_test.go index d77dd075..eeaad23a 100644 --- a/internal/plugin/proc/lock_test.go +++ b/internal/plugin/proc/lock_test.go @@ -72,20 +72,46 @@ func TestStageLock_ReleaseWithoutHoldRejected(t *testing.T) { } } -// handler 内嵌套加锁会死锁,应显式拒绝而不是让插件挂死到超时。 -func TestStageLock_ReentrantAcquireRejected(t *testing.T) { +// 同插件重入:**直接通过**,不报错也不死锁(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") - err := l.Acquire("A") - if err == nil { - t.Fatal("同一插件重复加锁应被拒绝(否则死锁 30s)") + 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 !strings.Contains(err.Error(), "重复申请") { - t.Errorf("错误信息应说明重复加锁,实际: %v", err) + if l.Owner() != "A" { + t.Errorf("重入后 Owner 应仍为 A,实际 %q", l.Owner()) } } diff --git a/internal/plugin/proc/stage.go b/internal/plugin/proc/stage.go index ae7a7029..9cb93bf9 100644 --- a/internal/plugin/proc/stage.go +++ b/internal/plugin/proc/stage.go @@ -41,6 +41,44 @@ func (r *lockRegistry) bind(l *stageLock) { 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 { r.mu.Lock() defer r.mu.Unlock() diff --git a/internal/plugin/proc/stage_lock_reentrant_test.go b/internal/plugin/proc/stage_lock_reentrant_test.go new file mode 100644 index 00000000..2c369911 --- /dev/null +++ b/internal/plugin/proc/stage_lock_reentrant_test.go @@ -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) + } +}