feat(hmapdev): 构建期告警「同一 stage 被重复注册」+ qq 合并写法

## 用户指出的关键:告警要给**开发者**看,所以必须在构建期

我第一版把告警放进了工具链生成的 proc_main 模板(插件**运行期**打日志)。
用户直接指出:「本身工具链这个编译器警告就是给开发者看的」——运行期日志
是给运维/用户看的,开发者看不到。已撤销,改为构建期静态检查。

## 缺陷(真实事故,2026-10-18)

插件侧把 stage handler 存在 `map[string]sdk.StageHandler`(stage 名为键)⇒
**同一 stage 注册两次,后者静默覆盖前者**。

QQ 插件当时写成:

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

后果不只是状态不切换 —— **权限门被静默关掉了**(那几版部署里任何人
发消息都能调任何工具)。编译器不报、运行时不报错、内核日志还显示
`2 handlers [qq qq]`(看着完全正常)。只有静态检查能发现。

## 实现

 · `stage_dup_check.go`:扫构建目录下的 .go(排除 _test.go 与 z_proc*),
   统计每个 stage 常量被 RegisterStage 引用几次,>1 就告警
 · 接入 `buildTarget` 开头 ⇒ 所有 target(含 lua)都检查
 · **只告警不中断**:存量插件里重复注册可能是本意,直接 fail 会让它们
   升 SDK 后无法构建
 · `example/qq`:三个重复 stage 合并成单 handler(权限门 + 状态切换)

## 判据(4 条)

 · 同 stage 两次 ⇒ 报出来
 · 不同 stage 各一次 ⇒ 不误报
 · **注释里的示例注册不计入**
 · 测试文件 / 工具链生成文件不计入

★ 「注释」这条是这个检查器**第一版自己栽的坑**:直接对全文跑正则,
把插件里「当初写错了、留在注释里当教训」的那几行也统计进去,于是
修好之后照样报「注册了 3 次」(实测)。现在逐行跳 `//` 与跨行 `/* */`。
本项目已多次绊倒在「注释里有、代码里没有」上,这次轮到检查器自己。

## 实测

	qq 修好后构建:  packaged qq_linux_amd64.hmap(无告警)
	故意加一条重复:  ⚠️ 警告: stage StageBeforeOutput 注册了 2 次
	                 建议:把多个功能合并进同一个 handler
This commit is contained in:
JianFeeeee
2026-10-18 15:38:52 +08:00
parent 678b9cf90c
commit 4a9705ca41
8 changed files with 386 additions and 183 deletions

View File

@ -842,20 +842,28 @@ type 枚举: text(文字)/ voice(语音转文字后发送)/ image(图
NoMemory: true, NoMemory: true,
}, p.handleSendLike) }, p.handleSendLike)
// 全局权限门:只有 QQ 当前输入需要受此插件约束;Bot 所有者始终完整放行。 // ★★ 每个 stage **只能注册一个 handler**(2026-10-18 踩到的坑)。
s.RegisterStage(sdk.StageOnInput, p.onInputAuthContext, sdk.StageScopeGlobal)
s.RegisterStage(sdk.StageBeforeToolcall, p.beforeToolcall, sdk.StageScopeGlobal)
// 在线状态随阶段切换(默认关闭,见 statusAutoEnabled 的注释)。
// //
// ★ 三个钩子都只有一件事:调 p.applyStatus;**永远 return nil**。 // 插件侧(工具链生成的 proc_main)把 handler 存在
// 状态是非致命错误 —— 不能因为它阻塞工具调用或改写回复。 // `stageHandlers map[string]StageHandler`(stage 名为键)⇒
s.RegisterStage(sdk.StageBeforeToolcall, p.stageStatusWorking, sdk.StageScopeGlobal) // **同一 stage 注册两次,后者静默覆盖前者**。而之前写成:
s.RegisterStage(sdk.StageBeforeOutput, p.stageStatusThinking, sdk.StageScopeGlobal) //
s.RegisterStage(sdk.StageAfterOutput, p.stageStatusOnline, sdk.StageScopeGlobal) // s.RegisterStage(StageBeforeToolcall, p.beforeToolcall, Global)
// s.RegisterStage(StageBeforeToolcall, p.stageStatusWorking, Global) ← 覆盖了上面
// s.RegisterStage(StageAfterOutput, p.stageStatusOnline, Global)
// s.RegisterStage(StageAfterOutput, p.afterOutputAuthContext, Global) ← 覆盖了上面
//
// 后果不只是状态不切换 —— **QQ 的权限门被静默关掉了**(那几版部署里
// 任何人发消息都能调任何工具)。hmapdev 现在会在构建期对这类重复告警。
//
// 现在合并成一个 handler,每个 stage 一次注册。
s.RegisterStage(sdk.StageOnInput, p.onInputAuthContext, sdk.StageScopeGlobal)
s.RegisterStage(sdk.StageBeforeToolcall, p.stageBeforeToolcall, sdk.StageScopeGlobal)
s.RegisterStage(sdk.StageBeforeOutput, p.stageBeforeOutput, sdk.StageScopeGlobal)
s.RegisterStage(sdk.StageAfterOutput, p.stageAfterOutput, sdk.StageScopeGlobal)
// before_toolcall 的 Response 只用于拒绝当前工具。下一次模型补全到达时必须清掉, // before_toolcall 的 Response 只用于拒绝当前工具。下一次模型补全到达时必须清掉,
// 否则内核会把它误当作“结束整个推理”的最终响应。 // 否则内核会把它误当作“结束整个推理”的最终响应。
s.RegisterStage(sdk.StagePostAction, p.clearDeniedResponse, sdk.StageScopeGlobal) s.RegisterStage(sdk.StagePostAction, p.clearDeniedResponse, sdk.StageScopeGlobal)
s.RegisterStage(sdk.StageAfterOutput, p.afterOutputAuthContext, sdk.StageScopeGlobal)
// ---- HTTP server for NapCat webhook ---- // ---- HTTP server for NapCat webhook ----
mux := http.NewServeMux() mux := http.NewServeMux()

View File

@ -88,6 +88,14 @@ var qqStatusByName = map[string]qqStatus{
"离线": qqStatusHidden, "离线": qqStatusHidden,
} }
// minStatusInterval 是两次自动状态切换的最小间隔。
//
// 取 2 秒:stage 在每次工具调用都会触发,一轮对话可能有十几次;
// 逐次发 HTTP 会把 NapCat 打满,且 QQ 侧看到状态疯狂闪烁。
//
// ★ 收尾(StageAfterOutput)**不受此限**,见 applyStatusForce。
const minStatusInterval = 2 * time.Second
// statusState 是自动状态切换的运行态。 // statusState 是自动状态切换的运行态。
type statusState struct { type statusState struct {
mu sync.Mutex mu sync.Mutex
@ -133,6 +141,27 @@ func (p *Plugin) setSelfDiyStatus(faceID, wording string) error {
// ★ 这里刻意**不做排队重试**:状态是"当前观感",错过一次就在下一个 stage 补上, // ★ 这里刻意**不做排队重试**:状态是"当前观感",错过一次就在下一个 stage 补上,
// 没必要维护队列(队列会让状态滞后于阶段,反而更错)。 // 没必要维护队列(队列会让状态滞后于阶段,反而更错)。
func (p *Plugin) applyStatus(st qqStatus) { func (p *Plugin) applyStatus(st qqStatus) {
p.applyStatusMaybe(st, true)
}
// applyStatusForce 跳过防抖(用于"收尾":一轮结束时必须回在线)。
//
// ## 为什么必须有这个分支(2026-10-18 实测 bug)
//
// QQ 界面持续停「学习中」。原因:
//
// BeforeOutput → AfterOutput 常常不足 2 秒
// ⇒ 收尾状态被防抖拦住 ⇒ **永远回不到在线**
//
// 防抖本身是对的(防刷 NapCat),但**收尾不能被防抖** ——
// 它是一轮结束时唯一的"归位"机会,错过就得等下一轮,
// 那期间好友看到的是错的状态。
func (p *Plugin) applyStatusForce(st qqStatus) {
p.applyStatusMaybe(st, false)
}
// applyStatusMaybe 是 applyStatus / applyStatusForce 的共同实现。
func (p *Plugin) applyStatusMaybe(st qqStatus, debounce bool) {
if !p.statusAutoEnabled.Load() { if !p.statusAutoEnabled.Load() {
return return
} }
@ -141,7 +170,7 @@ func (p *Plugin) applyStatus(st qqStatus) {
p.statusState.mu.Unlock() p.statusState.mu.Unlock()
return return
} }
if time.Since(p.statusState.lastAt) < 2*time.Second { if debounce && time.Since(p.statusState.lastAt) < minStatusInterval {
p.statusState.mu.Unlock() p.statusState.mu.Unlock()
return return
} }
@ -249,23 +278,6 @@ func normalizeStatusName(s string) (qqStatus, bool) {
// //
// 这与 typing(set_input_status)的处置一致:那套也从不因失败中断对话。 // 这与 typing(set_input_status)的处置一致:那套也从不因失败中断对话。
func (p *Plugin) stageStatusWorking(ctx *sdk.StageContext) error {
p.applyStatus(qqStatusWorking)
return nil
}
func (p *Plugin) stageStatusThinking(ctx *sdk.StageContext) error {
// "生成回复中"用「学习中」的预置状态:QQ 没有"思考中"这个预置项。
// 想要精确文字可调 qq_set_diy_status 工具(自定义 face_id + wording)。
p.applyStatus(qqStatusStudy)
return nil
}
func (p *Plugin) stageStatusOnline(ctx *sdk.StageContext) error {
p.applyStatus(qqStatusOnline)
return nil
}
// ======== 工具 handler ======== // ======== 工具 handler ========
// handleSetStatus 处理 qq_set_status:手动设预置在线状态。 // handleSetStatus 处理 qq_set_status:手动设预置在线状态。
@ -319,3 +331,54 @@ func (p *Plugin) handleSetDiyStatus(args map[string]interface{}) (interface{}, e
"wording": wording, "wording": wording,
}, nil }, nil
} }
// ======== 合并后的 stage 入口 ========
//
// ★★ 为什么必须合并(2026-10-18 踩到的真坑)
//
// 工具链生成的 proc_main 把 stage handler 存在
// `stageHandlers map[string]sdk.StageHandler`(**stage 名为键**):
//
// func(stage sdk.Stage, handler sdk.StageHandler) {
// stageHandlers[string(stage)] = handler // ← 后者覆盖前者
// }
//
// 所以一个插件**每个 stage 只能有一个 handler**。原先写成两行注册:
//
// RegisterStage(StageBeforeToolcall, p.beforeToolcall, Global) ← 权限门
// RegisterStage(StageBeforeToolcall, p.stageStatusWorking, Global) ← 覆盖了它
//
// 后果不只是"状态不切换"——**QQ 的权限门被静默关掉了**。
// hmapdev 现在会在**构建期**对这类重复告警(开发者在构建输出里必然看见)。
// stageBeforeToolcall 把「权限门」与「状态切换」合成一次注册。
//
// 顺序:先跑权限门(它可能拒绝工具),再切状态。
// 即使权限门拒绝,状态也该显示"在干活"——拒绝也是 agent 在工作。
func (p *Plugin) stageBeforeToolcall(ctx *sdk.StageContext) error {
if err := p.beforeToolcall(ctx); err != nil {
return err
}
p.applyStatus(qqStatusWorking)
return nil
}
// stageBeforeOutput 把「状态切换」接到 before_output(该 stage 无其它 handler)。
func (p *Plugin) stageBeforeOutput(ctx *sdk.StageContext) error {
// "生成回复中"用「学习中」的预置状态:QQ 没有"思考中"这个预置项。
// 想要精确文字可调 qq_set_diy_status 工具(自定义 face_id + wording)。
p.applyStatus(qqStatusStudy)
return nil
}
// stageAfterOutput 把「清理 auth 上下文」与「状态归位」合成一次注册。
//
// 顺序:先清理(原有语义,必须执行),再切状态(**force**,不受防抖)。
// 清理不能因状态失败而跳过 —— 它关系到下一轮的权限判定。
func (p *Plugin) stageAfterOutput(ctx *sdk.StageContext) error {
if err := p.afterOutputAuthContext(ctx); err != nil {
return err
}
p.applyStatusForce(qqStatusOnline)
return nil
}

View File

@ -39,9 +39,9 @@ func TestStatusStageNeverBlocksOnFailure(t *testing.T) {
name string name string
fn func(*sdk.StageContext) error fn func(*sdk.StageContext) error
}{ }{
{"before_toolcall", p.stageStatusWorking}, {"before_toolcall", p.stageBeforeToolcall},
{"before_output", p.stageStatusThinking}, {"before_output", p.stageBeforeOutput},
{"after_output", p.stageStatusOnline}, {"after_output", p.stageAfterOutput},
} { } {
if err := c.fn(ctx); err != nil { if err := c.fn(ctx); err != nil {
t.Fatalf("★ %s 返回了 error(%v)—— 状态设置是非致命错误,"+ t.Fatalf("★ %s 返回了 error(%v)—— 状态设置是非致命错误,"+
@ -55,7 +55,7 @@ func TestStatusStageNoopWhenDisabled(t *testing.T) {
p := &Plugin{name: "qq", napcatURL: "http://127.0.0.1:1"} p := &Plugin{name: "qq", napcatURL: "http://127.0.0.1:1"}
// statusAutoEnabled 默认零值 false // statusAutoEnabled 默认零值 false
ctx := &sdk.StageContext{} ctx := &sdk.StageContext{}
if err := p.stageStatusWorking(ctx); err != nil { if err := p.stageBeforeToolcall(ctx); err != nil {
t.Fatalf("开关关闭时不应出错: %v", err) t.Fatalf("开关关闭时不应出错: %v", err)
} }
p.statusState.mu.Lock() p.statusState.mu.Lock()

View File

@ -684,6 +684,17 @@ func resolveSDKPath(sdkPath string) string {
func buildTarget(plg *PlgConfig, target, outDir, sdkPath string) { func buildTarget(plg *PlgConfig, target, outDir, sdkPath string) {
os.MkdirAll(outDir, 0755) os.MkdirAll(outDir, 0755)
// ★ 构建期静态检查:同一 stage 被重复注册。
//
// 这类错误编译器不报、运行时不报错、内核日志看着正常
// (`2 handlers [qq qq]`),只有源码检查能发现。
// 告警打在构建输出里 —— 开发者必然看见(用户原话:
// 「工具链这个编译器警告就是给开发者看的」)。
//
// 不中断构建:存量插件里重复注册可能是本意,直接 fail 会让它们
// 升 SDK 后无法构建。
warnDuplicateStageRegs(".")
// Lua: no compilation, package source directly // Lua: no compilation, package source directly
if target == "lua" { if target == "lua" {
writePluginJSON(plg, nil, "main.lua") writePluginJSON(plg, nil, "main.lua")

View File

@ -0,0 +1,136 @@
package main
// 构建期检查:同一 stage 被重复注册。
//
// ## 为什么在构建期(而不是运行期)
//
// 工具链生成的 proc_main 把 stage handler 存在 map 里(stage 名为键):
//
// stageHandlers[string(stage)] = handler // 后者静默覆盖前者
//
// 于是写成两行注册就会丢功能 —— 更糟的是,如果其中一个是**权限门**,
// 覆盖就意味着**权限被静默关掉**(2026-10-18 真实事故:那几版 QQ 插件里
// 任何人发消息都能调任何工具)。
//
// 这类错误编译器不报、运行时不报错、内核日志看着正常(`2 handlers [qq qq]`),
// 只有**源码静态检查**能发现。
//
// ## 为什么告警在构建期而非运行期
//
// 用户原话:「本身工具链这个编译器警告就是给开发者看的」。
// 运行期日志是给**运维/用户**看的,开发者看不到;而构建是开发者必经的一步,
// 告警打在构建输出里他必然看见。
//
// ## 为什么是告警而不是错误
//
// 存量插件里重复注册可能是本意(权限门 + 埋点分两处写)。
// 直接 fail 会让它们升 SDK 后无法构建。告警足够达到"开发者能发现"的目的,
// 想消除告警就把多个功能合并进同一个 handler。
import (
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
)
// stageRegRe 抓 `<任意>.RegisterStage(sdk.StageXxx`, 也容忍不带 sdk. 前缀的写法。
var stageRegRe = regexp.MustCompile(`\.RegisterStage\(\s*(?:sdk\.)?(Stage[A-Za-z]+)`)
// duplicateStageRegs 扫描目录下的 .go 源码(排除测试与生成文件),
// 返回 "Stage名 -> 出现次数" 中次数 > 1 的项。
//
// 只看 RegisterStage 的第一个参数是否为**同一个 stage 常量**。
// 不做语义分析(不需要):同一 stage 注册两次就是这个 bug 的充要形态。
func duplicateStageRegs(dir string) (map[string]int, error) {
counts := map[string]int{}
entries, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".go") {
continue
}
// 排除测试(它们会故意造重复来验证判据)与工具链生成文件
// (生成文件里的注册是模板展开,不是作者写的)。
if strings.HasSuffix(e.Name(), "_test.go") || strings.HasPrefix(e.Name(), "z_proc") {
continue
}
b, err := os.ReadFile(filepath.Join(dir, e.Name()))
if err != nil {
continue
}
// ★ 必须**逐行跳注释**:注释掉的示例注册不计入。
//
// 这个检查器第一版直接对全文跑正则,把插件里“当初写错了、现在
// 保留在注释里当教训”的那几行也统计进去,于是修好之后照样报
// “注册了 3 次”(实测)。而“注释里有、代码里没有”这个陷阱
// 在本项目已经绊倒过好几次 —— 这次轮到检查器自己。
inBlock := false
for _, ln := range strings.Split(string(b), "\n") {
code := strings.TrimSpace(ln)
// 块注释(/* ... */)可能跨行,逐行维护状态
if inBlock {
if i := strings.Index(code, "*/"); i >= 0 {
code = strings.TrimSpace(code[i+2:])
inBlock = false
} else {
continue
}
}
if strings.HasPrefix(code, "//") || strings.HasPrefix(code, "*") {
continue
}
if strings.HasPrefix(code, "/*") {
if i := strings.Index(code, "*/"); i >= 0 {
code = strings.TrimSpace(code[i+2:])
} else {
inBlock = true
continue
}
}
// 行尾注释:只取 // 之前的部分(字符串里的 // 罕见,且
// 这里关心的只是 RegisterStage 调用,不会出现在字符串里)。
if i := strings.Index(code, "//"); i >= 0 {
code = strings.TrimSpace(code[:i])
}
for _, m := range stageRegRe.FindAllStringSubmatch(code, -1) {
counts[m[1]]++
}
}
}
dups := map[string]int{}
for k, v := range counts {
if v > 1 {
dups[k] = v
}
}
return dups, nil
}
// warnDuplicateStageRegs 打印构建期告警(不中断构建)。
//
// 返回是否有重复(供调用方决定是否在汇总里体现)。
func warnDuplicateStageRegs(dir string) bool {
dups, err := duplicateStageRegs(dir)
if err != nil || len(dups) == 0 {
return false
}
names := make([]string, 0, len(dups))
for k := range dups {
names = append(names, k)
}
sort.Strings(names)
fmt.Printf("\n")
for _, n := range names {
fmt.Printf(" ⚠️ 警告: stage %s 注册了 %d 次\n", n, dups[n])
}
fmt.Printf(" 插件侧 stageHandlers 是 map(stage 名为键)⇒ 只有**最后**一次注册生效,\n")
fmt.Printf(" 前面的会被静默覆盖。若其中一个是权限门,覆盖 = 权限被关掉。\n")
fmt.Printf(" 建议:把多个功能合并进同一个 handler(减少跨进程 stage 往返)。\n\n")
return true
}

View File

@ -0,0 +1,135 @@
package main
// 构建期检查的判据:同 stage 重复注册必须被**构建期**发现。
//
// ## 为什么是构建期
//
// 用户原话:「本身工具链这个编译器警告就是给开发者看的」。
// 运行期日志给运维看,开发者看不到;构建是开发者必经的一步。
//
// ## 这类错误为什么只能静态查
//
// 插件侧 stageHandlers 是 `map[string]StageHandler`(stage 名为键)⇒
// 同 stage 注册两次,**后者静默覆盖前者**。编译器不报、运行时不报错、
// 内核日志还显示 `2 handlers [qq qq]`(看着正常,实际只派发一次)。
// 2026-10-18 因此把 QQ 的**权限门**覆盖掉了。
//
// ## 判据覆盖的点
//
// · 同 stage 两次 ⇒ 报出来
// · 不同 stage 各一次 ⇒ 不误报
// · **注释里的示例注册不计入**(这个检查器第一版就栽在这,
// 把"当初写错了、留在注释里当教训"的代码算进去,修好之后照样告警)
import (
"os"
"path/filepath"
"testing"
)
func writeGo(t *testing.T, dir, name, content string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func TestDuplicateStageRegsDetects(t *testing.T) {
dir := t.TempDir()
writeGo(t, dir, "plugin.go", `
package main
func reg(s *sdk.PluginSDK, p *P) {
s.RegisterStage(sdk.StageBeforeToolcall, p.auth, sdk.StageScopeGlobal)
s.RegisterStage(sdk.StageBeforeToolcall, p.status, sdk.StageScopeGlobal)
}
`)
dups, err := duplicateStageRegs(dir)
if err != nil {
t.Fatal(err)
}
if dups["StageBeforeToolcall"] != 2 {
t.Fatalf("★ 没检出 StageBeforeToolcall 重复,实际 %v", dups)
}
}
func TestDuplicateStageRegsNoFalsePositive(t *testing.T) {
dir := t.TempDir()
writeGo(t, dir, "plugin.go", `
package main
func reg(s *sdk.PluginSDK, p *P) {
s.RegisterStage(sdk.StageOnInput, p.auth, sdk.StageScopeGlobal)
s.RegisterStage(sdk.StageBeforeToolcall, p.before, sdk.StageScopeGlobal)
s.RegisterStage(sdk.StageAfterOutput, p.after, sdk.StageScopeGlobal)
}
`)
dups, err := duplicateStageRegs(dir)
if err != nil {
t.Fatal(err)
}
if len(dups) != 0 {
t.Fatalf("★ 不同 stage 各一次不该报,实际 %v", dups)
}
}
// ★★★ 判据:注释里的示例注册**不计入**。
//
// 这个检查器第一版直接对全文跑正则,于是把插件里
// 「当初写错了、现在留在注释里当教训」的那几行也统计进去,
// 修好之后照样报"注册了 3 次"(实测)。
func TestDuplicateStageRegsIgnoresComments(t *testing.T) {
dir := t.TempDir()
writeGo(t, dir, "plugin.go", `
package main
func reg(s *sdk.PluginSDK, p *P) {
// s.RegisterStage(StageBeforeToolcall, p.beforeToolcall, Global)
// s.RegisterStage(StageBeforeToolcall, p.stageStatusWorking, Global) ← 覆盖了上面
/*
s.RegisterStage(StageAfterOutput, p.stageStatusOnline, Global)
*/
s.RegisterStage(sdk.StageBeforeToolcall, p.merged, sdk.StageScopeGlobal)
s.RegisterStage(sdk.StageAfterOutput, p.mergedAfter, sdk.StageScopeGlobal)
}
`)
dups, err := duplicateStageRegs(dir)
if err != nil {
t.Fatal(err)
}
if len(dups) != 0 {
t.Fatalf("★ 注释里的示例注册被当成真注册,实际 %v —— "+
"这会让\"已修好\"的插件照样告警(实测踩过)", dups)
}
}
// ★★★ 判据:测试文件与工具链生成文件不计入。
func TestDuplicateStageRegsSkipsTestAndGenerated(t *testing.T) {
dir := t.TempDir()
writeGo(t, dir, "plugin.go", `
package main
func reg(s *sdk.PluginSDK, p *P) {
s.RegisterStage(sdk.StageBeforeToolcall, p.a, sdk.StageScopeGlobal)
}
`)
// 测试文件里故意造重复(是判据的需要,不是真缺陷)
writeGo(t, dir, "plugin_test.go", `
package main
func t1(s *sdk.PluginSDK) {
s.RegisterStage(sdk.StageBeforeToolcall, nil)
s.RegisterStage(sdk.StageBeforeToolcall, nil)
}
`)
// 生成文件里的注册是模板展开,不是作者写的
writeGo(t, dir, "z_proc_gen.go", `
package main
func g(s *sdk.PluginSDK) {
s.RegisterStage(sdk.StageBeforeToolcall, nil)
s.RegisterStage(sdk.StageBeforeToolcall, nil)
}
`)
dups, err := duplicateStageRegs(dir)
if err != nil {
t.Fatal(err)
}
if len(dups) != 0 {
t.Fatalf("★ 测试/生成文件被计入,实际 %v", dups)
}
}

View File

@ -1,109 +0,0 @@
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,50 +708,9 @@ 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 {