From 4a9705ca41abd1c8430a384cec1d7ca654185b54 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 18 Oct 2026 15:38:52 +0800 Subject: [PATCH] =?UTF-8?q?feat(hmapdev):=20=E6=9E=84=E5=BB=BA=E6=9C=9F?= =?UTF-8?q?=E5=91=8A=E8=AD=A6=E3=80=8C=E5=90=8C=E4=B8=80=20stage=20?= =?UTF-8?q?=E8=A2=AB=E9=87=8D=E5=A4=8D=E6=B3=A8=E5=86=8C=E3=80=8D+=20qq=20?= =?UTF-8?q?=E5=90=88=E5=B9=B6=E5=86=99=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 用户指出的关键:告警要给**开发者**看,所以必须在构建期 我第一版把告警放进了工具链生成的 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 --- example/qq/plugin.go | 28 +++-- example/qq/status.go | 99 +++++++++++++--- example/qq/status_test.go | 8 +- tools/hmapdev/cmd_build.go | 11 ++ tools/hmapdev/stage_dup_check.go | 136 ++++++++++++++++++++++ tools/hmapdev/stage_dup_check_test.go | 135 +++++++++++++++++++++ tools/hmapdev/stage_merge_test.go | 109 ----------------- tools/hmapdev/templates/proc_main.go.tmpl | 43 +------ 8 files changed, 386 insertions(+), 183 deletions(-) create mode 100644 tools/hmapdev/stage_dup_check.go create mode 100644 tools/hmapdev/stage_dup_check_test.go delete mode 100644 tools/hmapdev/stage_merge_test.go diff --git a/example/qq/plugin.go b/example/qq/plugin.go index 0c05094..0243eae 100644 --- a/example/qq/plugin.go +++ b/example/qq/plugin.go @@ -842,20 +842,28 @@ type 枚举: text(文字)/ voice(语音转文字后发送)/ image(图 NoMemory: true, }, p.handleSendLike) - // 全局权限门:只有 QQ 当前输入需要受此插件约束;Bot 所有者始终完整放行。 - s.RegisterStage(sdk.StageOnInput, p.onInputAuthContext, sdk.StageScopeGlobal) - s.RegisterStage(sdk.StageBeforeToolcall, p.beforeToolcall, sdk.StageScopeGlobal) - // 在线状态随阶段切换(默认关闭,见 statusAutoEnabled 的注释)。 + // ★★ 每个 stage **只能注册一个 handler**(2026-10-18 踩到的坑)。 // - // ★ 三个钩子都只有一件事:调 p.applyStatus;**永远 return nil**。 - // 状态是非致命错误 —— 不能因为它阻塞工具调用或改写回复。 - s.RegisterStage(sdk.StageBeforeToolcall, p.stageStatusWorking, sdk.StageScopeGlobal) - s.RegisterStage(sdk.StageBeforeOutput, p.stageStatusThinking, sdk.StageScopeGlobal) - s.RegisterStage(sdk.StageAfterOutput, p.stageStatusOnline, sdk.StageScopeGlobal) + // 插件侧(工具链生成的 proc_main)把 handler 存在 + // `stageHandlers map[string]StageHandler`(stage 名为键)⇒ + // **同一 stage 注册两次,后者静默覆盖前者**。而之前写成: + // + // 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 只用于拒绝当前工具。下一次模型补全到达时必须清掉, // 否则内核会把它误当作“结束整个推理”的最终响应。 s.RegisterStage(sdk.StagePostAction, p.clearDeniedResponse, sdk.StageScopeGlobal) - s.RegisterStage(sdk.StageAfterOutput, p.afterOutputAuthContext, sdk.StageScopeGlobal) // ---- HTTP server for NapCat webhook ---- mux := http.NewServeMux() diff --git a/example/qq/status.go b/example/qq/status.go index ec26ab8..5e6d551 100644 --- a/example/qq/status.go +++ b/example/qq/status.go @@ -88,6 +88,14 @@ var qqStatusByName = map[string]qqStatus{ "离线": qqStatusHidden, } +// minStatusInterval 是两次自动状态切换的最小间隔。 +// +// 取 2 秒:stage 在每次工具调用都会触发,一轮对话可能有十几次; +// 逐次发 HTTP 会把 NapCat 打满,且 QQ 侧看到状态疯狂闪烁。 +// +// ★ 收尾(StageAfterOutput)**不受此限**,见 applyStatusForce。 +const minStatusInterval = 2 * time.Second + // statusState 是自动状态切换的运行态。 type statusState struct { mu sync.Mutex @@ -133,6 +141,27 @@ func (p *Plugin) setSelfDiyStatus(faceID, wording string) error { // ★ 这里刻意**不做排队重试**:状态是"当前观感",错过一次就在下一个 stage 补上, // 没必要维护队列(队列会让状态滞后于阶段,反而更错)。 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() { return } @@ -141,7 +170,7 @@ func (p *Plugin) applyStatus(st qqStatus) { p.statusState.mu.Unlock() return } - if time.Since(p.statusState.lastAt) < 2*time.Second { + if debounce && time.Since(p.statusState.lastAt) < minStatusInterval { p.statusState.mu.Unlock() return } @@ -249,23 +278,6 @@ func normalizeStatusName(s string) (qqStatus, bool) { // // 这与 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 ======== // handleSetStatus 处理 qq_set_status:手动设预置在线状态。 @@ -319,3 +331,54 @@ func (p *Plugin) handleSetDiyStatus(args map[string]interface{}) (interface{}, e "wording": wording, }, 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 +} diff --git a/example/qq/status_test.go b/example/qq/status_test.go index 386d63b..d978863 100644 --- a/example/qq/status_test.go +++ b/example/qq/status_test.go @@ -39,9 +39,9 @@ func TestStatusStageNeverBlocksOnFailure(t *testing.T) { name string fn func(*sdk.StageContext) error }{ - {"before_toolcall", p.stageStatusWorking}, - {"before_output", p.stageStatusThinking}, - {"after_output", p.stageStatusOnline}, + {"before_toolcall", p.stageBeforeToolcall}, + {"before_output", p.stageBeforeOutput}, + {"after_output", p.stageAfterOutput}, } { if err := c.fn(ctx); err != nil { 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"} // statusAutoEnabled 默认零值 false ctx := &sdk.StageContext{} - if err := p.stageStatusWorking(ctx); err != nil { + if err := p.stageBeforeToolcall(ctx); err != nil { t.Fatalf("开关关闭时不应出错: %v", err) } p.statusState.mu.Lock() diff --git a/tools/hmapdev/cmd_build.go b/tools/hmapdev/cmd_build.go index f4640ed..21b72c9 100644 --- a/tools/hmapdev/cmd_build.go +++ b/tools/hmapdev/cmd_build.go @@ -684,6 +684,17 @@ func resolveSDKPath(sdkPath string) string { func buildTarget(plg *PlgConfig, target, outDir, sdkPath string) { os.MkdirAll(outDir, 0755) + // ★ 构建期静态检查:同一 stage 被重复注册。 + // + // 这类错误编译器不报、运行时不报错、内核日志看着正常 + // (`2 handlers [qq qq]`),只有源码检查能发现。 + // 告警打在构建输出里 —— 开发者必然看见(用户原话: + // 「工具链这个编译器警告就是给开发者看的」)。 + // + // 不中断构建:存量插件里重复注册可能是本意,直接 fail 会让它们 + // 升 SDK 后无法构建。 + warnDuplicateStageRegs(".") + // Lua: no compilation, package source directly if target == "lua" { writePluginJSON(plg, nil, "main.lua") diff --git a/tools/hmapdev/stage_dup_check.go b/tools/hmapdev/stage_dup_check.go new file mode 100644 index 0000000..9daa316 --- /dev/null +++ b/tools/hmapdev/stage_dup_check.go @@ -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 +} diff --git a/tools/hmapdev/stage_dup_check_test.go b/tools/hmapdev/stage_dup_check_test.go new file mode 100644 index 0000000..b43dff9 --- /dev/null +++ b/tools/hmapdev/stage_dup_check_test.go @@ -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) + } +} diff --git a/tools/hmapdev/stage_merge_test.go b/tools/hmapdev/stage_merge_test.go deleted file mode 100644 index 23ed592..0000000 --- a/tools/hmapdev/stage_merge_test.go +++ /dev/null @@ -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 {` 分支") - } -} diff --git a/tools/hmapdev/templates/proc_main.go.tmpl b/tools/hmapdev/templates/proc_main.go.tmpl index d506469..46c7558 100644 --- a/tools/hmapdev/templates/proc_main.go.tmpl +++ b/tools/hmapdev/templates/proc_main.go.tmpl @@ -708,50 +708,9 @@ func buildPluginSDK(name string) *sdk.PluginSDK { }) }, 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() - 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() - - 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{}{ "stage": string(stage), "scope": "global", }); err != nil {