feat(hmapdev): 同 stage 多 handler 默认合并 + 注册期告警

## 修的是真实事故(2026-10-18)

模板里原来是:

	stageHandlers[string(stage)] = handler   // stage 名为键,后者覆盖前者

有人写了两次同 stage 注册:

	RegisterStage(BeforeToolcall, 权限门)   ← 被下一条覆盖
	RegisterStage(BeforeToolcall, 切状态)   ← 实际生效

后果不只是状态不切换 —— **权限门被静默关掉了**(那几版 QQ 插件里
任何人发消息都能调任何工具)。而内核侧日志显示 `2 handlers [qq qq]`,
看起来完全正常 —— 这是最难查的一类 bug,我为它白烧了十几轮。

## 处置(按用户要求:告警 + 默认合并)

	禁止重复 → 存量插件可能直接起不来(它们的重复常是本意)
	静默覆盖 → 就是当前 bug
	默认合并 → 两件事都做,且告警让开发者知道该优化写法 ← 选这个

合并语义三条:

 1. **按注册顺序链式执行**(先注册的先跑)
 2. **prev 返回 error 就中止**(与内核 RunStage 一致:权限门拒了
    就不该再切状态)
 3. **留痕**:注册期打 log.Printf 告警,开发者在插件启动时就能看见

★ 重复注册**不再**上报 stage.register:内核已经登记过一条,再报一条
会让它记两条 handler 而实际只派发一次 —— 那正是
`2 handlers [qq qq]` 假象的来源。

## 为什么改模板而不是 SDK

用户明确要求「工具链对同stage多handler注册给出告警」。模板是生成
proc_main 的地方,也就是 map 覆盖实际发生的地方;在源头拦,告警
直接出现在**插件启动日志**里。

## 判据(5 条,变异 3/3 咬)

 · 有"已存在"判断(不是无条件赋值)
 · 链式调用 prev(合并后旧 handler 不丢)
 · prev 出错中止
 · **告警是代码行**(跳过注释再找)+ 给出行动建议
 · `if dup` 分支内不含 stage.register

## 判据第一版是假绿

裸 `strings.Contains(body, "重复注册")` 把**注释里的解释**也算进去了,
于是删掉 log.Printf 后判据照样通过(变异不咬,实测)。
改成逐行跳过注释再找 log.Printf。这不是第一次栽在
「注释里有、代码里没有」上。
This commit is contained in:
JianFeeeee
2026-10-18 15:28:07 +08:00
parent 61a871cf88
commit 678b9cf90c
2 changed files with 151 additions and 1 deletions

View File

@ -0,0 +1,109 @@
package main
// 模板判据:同一 stage 多次注册必须**合并**而不是静默覆盖。
//
// ## 修的是什么(真实事故,2026-10-18)
//
// 模板里原来是:
//
// stageHandlers[string(stage)] = handler // stage 名为键,后者覆盖前者
//
// 有人写了两次同 stage 注册:
//
// RegisterStage(BeforeToolcall, 权限门) ← 被下一条覆盖
// RegisterStage(BeforeToolcall, 切状态) ← 实际生效
//
// 后果不只是状态不切换 —— **权限门被静默关掉了**(那几版 QQ 插件里
// 任何人发消息都能调任何工具)。而内核侧日志显示 `2 handlers [qq qq]`,
// 看起来完全正常 —— 这是最难查的一类 bug。
//
// ## 为什么判据在工具链而不是 SDK
//
// 用户明确要求:「是工具链对同stage多handler注册给出告警」。
// 工具链是生成 proc_main 的地方,也就是 map 覆盖实际发生的地方;
// 在源头拦,告警直接出现在**插件启动日志**里,开发者本地就能看到。
//
// ## 判据验什么
//
// · 存在"重复注册"分支(不是无条件赋值)
// · 合并是**链式**的(prev 先跑,返回 error 中止)
// · 有告警(log.Printf + 重复注册字样)
// · 重复注册**不**再次上报 stage.register(否则内核会记两条假 handler)
import (
"strings"
"testing"
)
func TestTemplateMergesDuplicateStageRegistrations(t *testing.T) {
data, err := procTemplates.ReadFile("templates/proc_main.go.tmpl")
if err != nil {
t.Fatal(err)
}
src := string(data)
// 定位 stage 注册闭包:从 `func(stage sdk.Stage, handler sdk.StageHandler) {` 起
i := strings.Index(src, "func(stage sdk.Stage, handler sdk.StageHandler) {")
if i < 0 {
t.Fatal("找不到 stage 注册闭包 —— 模板结构变了,判据需同步")
}
body := src[i:]
if j := strings.Index(body[1:], "\n\t\tfunc("); j > 0 {
body = body[:j+1]
}
// ① 必须有"已存在"的判断(不是无条件赋值)
if !strings.Contains(body, "prev, dup :=") && !strings.Contains(body, "dup := false") {
t.Fatalf("★ stage 注册没有检测重复 —— 仍在静默覆盖。\n片段:\n%s", body)
}
// ② 必须是**链式**合并:prev 先跑
if !strings.Contains(body, "prev(ctx)") {
t.Fatal("★ 重复注册没有链式调用 prev —— 合并后旧 handler 会丢失")
}
// ③ 第一个 handler 返回 error 必须中止(与内核 RunStage 语义一致)
if !strings.Contains(body, "if err := prev(ctx); err != nil {") {
t.Fatal("★ 链式合并没有在 prev 出错时中止 —— 权限门拒绝了还会继续执行后续 handler")
}
// ④ 必须告警,且必须是**代码行**(不能靠注释里的字眼蒙混)。
//
// 第一版写成裸 `strings.Contains(body, "重复注册")` —— 把注释里的解释
// 也算进去了,于是删掉 log.Printf 后判据照样通过(变异不咬,实测)。
// 必须跳过注释行再找。
var warnCode string
for _, ln := range strings.Split(body, "\n") {
code := strings.TrimSpace(ln)
if strings.HasPrefix(code, "//") || strings.HasPrefix(code, "*") {
continue
}
if strings.Contains(code, "log.Printf") && strings.Contains(code, "stage") {
warnCode = code
break
}
}
if warnCode == "" {
t.Fatal("★ 重复注册没有告警代码行 —— 开发者无从知道该优化写法")
}
// 告警文案要说明“建议合并写法”(带提示,而不只是“重复了”)
if !strings.Contains(body, "建议把多个功能合并进同一个 handler") {
t.Fatal("★ 告警没有给出行动建议(应提示合并写法)")
}
// ⑤ 重复注册不能再上报一次 stage.register
// (内核会记两条 handler,但实际只调度一次 ⇒ 制造 `2 handlers [qq qq]` 假象)
dupBlock := body
if k := strings.Index(body, "if dup {"); k >= 0 {
dupBlock = body[k:]
if m := strings.Index(dupBlock, "\n\t\t\t}"); m > 0 {
dupBlock = dupBlock[:m]
}
if strings.Contains(dupBlock, "stage.register") {
t.Fatal("★ 重复注册仍然上报了 stage.register —— " +
"内核会记录两条 handler 而实际只派发一次,制造假象")
}
} else {
t.Fatal("★ 找不到 `if dup {` 分支")
}
}

View File

@ -708,9 +708,50 @@ func buildPluginSDK(name string) *sdk.PluginSDK {
}) })
}, },
func(stage sdk.Stage, handler sdk.StageHandler) { func(stage sdk.Stage, handler sdk.StageHandler) {
// ★★ 同一 stage 多次注册 ⇒ **默认合并**(按注册顺序链式),并告警。
//
// ## 原先的静默覆盖造成过真实事故(2026-10-18)
//
// 原实现是 `stageHandlers[string(stage)] = handler`(stage 名为键)⇒
// 后者**静默覆盖**前者。有人写了:
//
// RegisterStage(BeforeToolcall, 权限门) ← 被下一条覆盖
// RegisterStage(BeforeToolcall, 切状态) ← 实际生效
//
// 后果不只是状态不切换 —— **权限门被静默关掉了**。
// 而内核侧日志显示 `2 handlers [qq qq]`,看着完全正常。
//
// 三种处置的取舍:
// 禁止重复 → 存量插件可能直接起不来(它们的重复常是本意)
// 静默覆盖 → 就是当前 bug
// 默认合并 → 两件事都做,且告警让开发者知道该优化写法 ← 选这个
//
// 告警在**注册期**(插件启动时)打到 stderr,开发者在本地跑就能看见。
// 想消除告警就把多个功能写进同一个 handler(本项目 QQ 插件的做法)。
handlerMu.Lock() handlerMu.Lock()
prev, dup := stageHandlers[string(stage)]
if dup {
// 链式:先跑已注册的,它返回 error 就中止(与内核 RunStage 语义一致:
// 错误要能拦住后面的处理——比如权限门拒了就不该再切状态)。
stageHandlers[string(stage)] = func(ctx *sdk.StageContext) error {
if err := prev(ctx); err != nil {
return err
}
return handler(ctx)
}
} else {
stageHandlers[string(stage)] = handler stageHandlers[string(stage)] = handler
}
handlerMu.Unlock() handlerMu.Unlock()
if dup {
log.Printf("[plugin] ⚠️ stage %q 重复注册(已按注册顺序链式合并)。"+
"建议把多个功能合并进同一个 handler —— 每次 stage 往返都有跨进程开销。", stage)
// ★ 重复注册**不**再次上报 stage.register:内核已经登记过一条,
// 再报一条会让内核以为有两个独立 handler(它确实会记两条,
// 但派发时只调用一次 —— 这就是之前那个 `2 handlers [qq qq]` 假象)。
return
}
if err := callCoreVoid("stage.register", map[string]interface{}{ if err := callCoreVoid("stage.register", map[string]interface{}{
"stage": string(stage), "scope": "global", "stage": string(stage), "scope": "global",
}); err != nil { }); err != nil {