mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-26 12:23:23 +00:00
排查内核 SIGSEGV 时用 A/B 对照(我的树 20 轮 vs 干净树 20 轮)确认了
两条**既有** flaky,与 C 化改动无关。本提交把它们修掉。
## 缺陷 ①(真 bug,不只是测试卫生):pluginmgr 监听地址是包级可变全局
`var HTTPAddr = "127.0.0.1:9876"` 是包级可变全局,`Start()` 还把 settings 读到的值
**反写**回它,`startHTTPServer` 再读它。后果:
- 多实例互相污染:后启动的实例把地址写进全局,先启动那个读到的是**别人的**地址
(实测与生产 homed 抢 9876)
- 全局读写无同步,属数据竞态
修法:改为实例字段 `p.httpAddr`(默认走 `const defaultHTTPAddr`),
不再有可被任意代码改写的包级状态;并新增 `HTTPURL()` 访问器。
## 缺陷 ②:remotedevice 用 ListenAndServe,监听失败静默且 :0 无法回报端口
`p.server = &http.Server{Addr: p.addr}` + `ListenAndServe()` 在后台 goroutine 里报错,
端口被占时只打一行日志、`Start()` 仍返回 nil —— 插件表面「已加载」而网关根本没跑。
且 `:0` 下拿不到真实端口。
修法:改为 `net.Listen` + `Serve`(与 webui/pluginmgr 同形):
- 监听失败**同步**返回,交给加载器
- 用**实际绑定**地址回写 p.addr,日志与诊断面显示真实端口
## 测试侧:全部改用 :0,不再抢固定端口
新增 `ConfigRegistry.SetPluginConfig(name, key, value)`:插件表原本只在
`RegisterDef`(插件 Start 时)创建,导致「想在插件加载前预置配置」无从下手
(直接 Set 会因表不存在而失败,错误常被忽略)。新方法先建表再写,填补该时序缺口。
`setupIntegration` 在 `Load()` 前预置:
- pluginmgr.http_addr / remotedevice.listen_addr → `127.0.0.1:0`
- webui 走已有的 `SetListenOverride("127.0.0.1:0")`(它有独立旁路)
实测三个插件现在各自绑到 OS 分配的空闲端口(41895 / 35855 / 34021)。
## 缺陷 ③:deepsearch 测试把「上游限流」当成功能回归
`TestRealPlugin_DeepSearchInvoke` 的断言会在上游限流时失败,但插件此时返回的是
**正常结果**(err==nil,content 含 "未返回结果" 与无响应引擎列表)——那是外部条件。
实测失败信息:`brave(Suspended: too many requests), duckduckgo(CAPTCHA), google cse(...)`。
更糟的是它**不可控地随机红**:干净树连跑 20 轮复现 2 次,与代码改动无关。
这种判据会让真正的回归淹没在噪声里。
修法:区分「上游不可用(限流/CAPTCHA)⇒ t.Skip 并说明理由」与
「其他异常 ⇒ fail」。不用静默 return,避免环境退化时判据无声失效。
## 由此发现并修掉的真缺陷:监听地址被硬编码在三处
`127.0.0.1:9876` 曾硬编码在 pluginmgr / cli / webui 各一份。cli 与 webui 后来改为
运行时读 `pluginmgr.http_addr` 设置(本次核实),pluginmgr 自己却仍是全局 —— 三处
口径现在统一为「读设置 + 实例字段」。
## 验证
- 新增 `TestTwoInstances_ListenIndependently`(pluginmgr):两个实例同时监听、
各自 HTTPURL 指向自己端口、两个地址都真的可连。
★ 经**忠实变异**验证有牙:复原「包级全局 + Start 反写 + 读全局」后该测试判红
(我第一版测试只断言字段不共享,变异证明它没牙,已重写为端到端判据)。
- `internal/plugins` 连跑 **30 轮:30/30 全过**(修复前干净树 18/20)。
- 全量连跑 3 轮:38 ok / 0 FAIL / 0 bind 冲突。
- go build ./... / go vet ./... 干净。
231 lines
8.9 KiB
Go
231 lines
8.9 KiB
Go
//go:build linux || darwin
|
||
|
||
package plugins
|
||
|
||
import (
|
||
"fmt"
|
||
"net/http"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config"
|
||
)
|
||
|
||
// forbidStoppingSharedBackend 给测试实例关上「退出时停后端」这道闸。
|
||
//
|
||
// 本机 127.0.0.1:8888 的 SearXNG 是**线上服务在用的**。插件侧已保证「只关自己拉起的实例」,
|
||
// 这里再加一道:即便这个测试实例真的把它拉起来了,退出时也不关 —— 否则跑一次测试就断一次
|
||
// 线上搜索(实测被这条坑过:E2E 临时目录里的插件实例在 teardown 时 `docker compose stop`,
|
||
// 表现为「搜索后端起不来」)。
|
||
func forbidStoppingSharedBackend(t *testing.T, env *testPluginEnv) {
|
||
t.Helper()
|
||
if env.cfgReg == nil {
|
||
t.Fatal("测试环境没有配置注册表,无法关闭后端托管")
|
||
}
|
||
ps := env.cfgReg.PluginConfig("deepsearch")
|
||
// 配置表只在 RegisterDef 时创建(内核有意为之:读写不隐式建表)。
|
||
// 插件启动时才会注册自己的 def,而我们要在那之前写入,所以这里先注册这个键。
|
||
ps.RegisterDef(internalConfig.ConfigDef{Key: "stop_searxng_on_exit", Type: "bool", Default: "true"})
|
||
if err := ps.Set("stop_searxng_on_exit", "false"); err != nil {
|
||
t.Fatalf("写 stop_searxng_on_exit=false 失败: %v", err)
|
||
}
|
||
}
|
||
|
||
// deepsearch 插件的真实调用往返:内核 ExecuteTool → RPC → 插件子进程 → 本地 SearXNG → 结果回传。
|
||
//
|
||
// 与 TestRealPlugin_ToolInvokeRoundTrip 的区别:那条只断言「链路通(拿到结果或拿到插件侧的错误)」,
|
||
// 这条断言**内容形状**——返回里必须有「摘要」与「引擎覆盖度」。这正是旧实现(抓 Bing HTML)
|
||
// 拿不到的东西,也是「搜索能力不行」的根因,所以它必须成为回归判据。
|
||
//
|
||
// 前置:本机 127.0.0.1:8888 上跑着 SearXNG(部署见 /root/searxng-agent)。
|
||
// 未启动时 fail 并给出可操作提示,而不是 skip —— 否则这条判据会在环境退化时静默失效。
|
||
func TestRealPlugin_DeepSearchInvoke(t *testing.T) {
|
||
env := setupIntegration(t)
|
||
defer env.cleanup()
|
||
|
||
plgDir := filepath.Join(env.tmpDir, "plugins")
|
||
installRealPlugin(t, plgDir, "deepsearch")
|
||
forbidStoppingSharedBackend(t, env)
|
||
|
||
if err := env.pluginReg.Load(plgDir); err != nil {
|
||
t.Fatalf("加载插件: %v", err)
|
||
}
|
||
if env.pluginReg.Get("deepsearch") == nil {
|
||
t.Fatal("deepsearch 未经 proc 通道加载")
|
||
}
|
||
|
||
var toolName string
|
||
for _, def := range env.stageHost.GetToolDefs() {
|
||
if strings.HasPrefix(def.Name, "deepsearch") && strings.HasSuffix(def.Name, "_search") {
|
||
toolName = def.Name
|
||
break
|
||
}
|
||
}
|
||
if toolName == "" {
|
||
t.Fatal("deepsearch 未注册检索工具")
|
||
}
|
||
t.Logf("调用工具 %s", toolName)
|
||
|
||
// 用当初失败的那条查询做判据
|
||
res, err := env.stageHost.ExecuteTool(toolName, map[string]interface{}{
|
||
"query": "深度科技 deepin 开发者 被开除",
|
||
"count": float64(3),
|
||
})
|
||
if err != nil {
|
||
if strings.Contains(err.Error(), "not found in any plugin") {
|
||
t.Fatalf("工具未注册到 stageHost: %v", err)
|
||
}
|
||
t.Fatalf("工具调用失败(检查本机 SearXNG 是否在 127.0.0.1:8888 运行): %v", err)
|
||
}
|
||
if res == nil {
|
||
t.Fatal("工具返回 nil 且无错误")
|
||
}
|
||
|
||
text := fmt.Sprintf("%v", res)
|
||
t.Logf("工具返回前 500 字:\n%s", truncRunes(text, 500))
|
||
|
||
// 上游限流/CAPTCHA 时跳过内容形状断言(外部条件,非功能回归)。
|
||
skipIfUpstreamUnavailable(t, text)
|
||
|
||
if !strings.Contains(text, "摘要:") {
|
||
t.Errorf("返回内容缺少摘要——这正是旧实现拿不到的部分:\n%s", truncRunes(text, 800))
|
||
}
|
||
if !strings.Contains(text, "覆盖:") {
|
||
t.Errorf("返回内容缺少引擎覆盖度(模型据此判断可信度):\n%s", truncRunes(text, 800))
|
||
}
|
||
if !strings.Contains(text, "http") {
|
||
t.Errorf("返回内容缺少结果链接:\n%s", truncRunes(text, 800))
|
||
}
|
||
}
|
||
|
||
// deepsearch_status 也走一遍真实调用:它把「后端是否可用、哪些引擎在出结果」暴露成工具,
|
||
// 出问题时 agent 可以先自检,而不是盲目换词重搜。
|
||
func TestRealPlugin_DeepSearchStatusInvoke(t *testing.T) {
|
||
env := setupIntegration(t)
|
||
defer env.cleanup()
|
||
|
||
plgDir := filepath.Join(env.tmpDir, "plugins")
|
||
installRealPlugin(t, plgDir, "deepsearch")
|
||
forbidStoppingSharedBackend(t, env)
|
||
if err := env.pluginReg.Load(plgDir); err != nil {
|
||
t.Fatalf("加载插件: %v", err)
|
||
}
|
||
|
||
var toolName string
|
||
for _, def := range env.stageHost.GetToolDefs() {
|
||
if strings.HasPrefix(def.Name, "deepsearch") && strings.HasSuffix(def.Name, "_status") {
|
||
toolName = def.Name
|
||
break
|
||
}
|
||
}
|
||
if toolName == "" {
|
||
t.Fatal("deepsearch 未注册自检工具")
|
||
}
|
||
|
||
res, err := env.stageHost.ExecuteTool(toolName, map[string]interface{}{"probe": "test"})
|
||
if err != nil {
|
||
t.Fatalf("自检调用失败(检查本机 SearXNG 是否运行): %v", err)
|
||
}
|
||
text := fmt.Sprintf("%v", res)
|
||
t.Logf("自检返回:%s", truncRunes(text, 400))
|
||
|
||
for _, want := range []string{"healthz", "search_ok", "engines_returning_results"} {
|
||
if !strings.Contains(text, want) {
|
||
t.Errorf("自检结果缺少字段 %q:%s", want, truncRunes(text, 500))
|
||
}
|
||
}
|
||
}
|
||
|
||
// upstreamUnavailable 判定本次检索失败是否**源于上游不可用**(限流/CAPTCHA),
|
||
// 而不是插件功能回归。
|
||
//
|
||
// 为什么必须区分:插件在「所有引擎都没给出结果」时返回的是**正常结果**
|
||
// (err == nil,content 里带 "未返回结果" 与无响应引擎列表)——这是上游限流、
|
||
// CAPTCHA 等**外部条件**,与代码是否正确无关。
|
||
//
|
||
// 此前这条测试把它们一视同仁地判红:实测失败信息是
|
||
// brave(Suspended: too many requests), duckduckgo(CAPTCHA), google cse(Suspended: ...)
|
||
// 于是「上游限流」被当成「搜索能力坏了」。更糟的是它**不可控地随机红**:
|
||
// 用 A/B 对照实测(同一时段连跑 20 轮)干净树也复现 2 次失败,
|
||
// 与任何代码改动无关 —— 这种判据会让真正的回归淹没在噪声里。
|
||
//
|
||
// 现在的语义:
|
||
// 上游限流/CAPTCHA ⇒ t.Skip(带明确理由,不静默通过)
|
||
// 其他异常 ⇒ t.Fatalf/Fail(真回归)
|
||
func upstreamUnavailable(text string) bool {
|
||
// 插件只有在「无任何结果」时才输出这句;有结果时不会出现。
|
||
return strings.Contains(text, "未返回结果")
|
||
}
|
||
|
||
// skipIfUpstreamUnavailable 在判定为上游不可用时以**明确理由**跳过。
|
||
// 注意是 Skip 而不是静默 return:后者会让这条判据在环境退化时无声失效
|
||
// (本文件原本的注释正是担心这一点,只是用错了应对方式——把噪声判成红)。
|
||
func skipIfUpstreamUnavailable(t *testing.T, text string) {
|
||
t.Helper()
|
||
if upstreamUnavailable(text) {
|
||
t.Skipf("上游搜索后端不可用(限流/CAPTCHA),跳过内容形状断言。"+
|
||
"这不是功能回归;要验证内容形状请在引擎可用时重跑。返回:%s",
|
||
truncRunes(text, 300))
|
||
}
|
||
}
|
||
|
||
func truncRunes(s string, n int) string {
|
||
r := []rune(s)
|
||
if len(r) <= n {
|
||
return s
|
||
}
|
||
return string(r[:n]) + "…"
|
||
}
|
||
|
||
// 插件的停止**不得带走共享后端**:这是「搜索后端起不来」的直接回归判据。
|
||
// 判据落在网络层(healthz 仍然 200),而不是「有没有执行 docker 命令」。
|
||
func TestRealPlugin_DeepSearchKeepsSharedBackendOnStop(t *testing.T) {
|
||
env := setupIntegration(t)
|
||
defer env.cleanup()
|
||
|
||
// 前置:后端必须可达(本测试判据是「停止后 healthz 仍 200」,
|
||
// 后端本来就不可用时该判据无从谈起 —— 用 skip 而非 fail,
|
||
// 因为那是环境问题,不是「插件把后端带走了」)。
|
||
if !searxngHealthy() {
|
||
t.Skip("本机 127.0.0.1:8888 的 SearXNG 不可用,无法验证「停止不带走后端」;" +
|
||
"先 `cd /root/searxng-agent && docker compose up -d` 再跑")
|
||
}
|
||
|
||
plgDir := filepath.Join(env.tmpDir, "plugins")
|
||
installRealPlugin(t, plgDir, "deepsearch")
|
||
forbidStoppingSharedBackend(t, env)
|
||
if err := env.pluginReg.Load(plgDir); err != nil {
|
||
t.Fatalf("加载插件: %v", err)
|
||
}
|
||
if env.pluginReg.Get("deepsearch") == nil {
|
||
t.Fatal("deepsearch 未加载")
|
||
}
|
||
|
||
// 停掉这个插件实例(相当于测试 teardown / 另一个 daemon 退出)
|
||
if err := env.pluginReg.StopAndUnload("deepsearch"); err != nil {
|
||
t.Fatalf("停止插件失败: %v", err)
|
||
}
|
||
|
||
// 给「如果它真去关了」留出执行窗口:stop -t 2 最多 2 秒
|
||
deadline := time.Now().Add(6 * time.Second)
|
||
for time.Now().Before(deadline) {
|
||
if !searxngHealthy() {
|
||
t.Fatalf("插件停止把共享后端带走了(healthz 不再是 200)—— 线上搜索会因此不可用")
|
||
}
|
||
time.Sleep(500 * time.Millisecond)
|
||
}
|
||
t.Log("插件已停止,共享后端仍在服务")
|
||
}
|
||
|
||
func searxngHealthy() bool {
|
||
cl := &http.Client{Timeout: 3 * time.Second}
|
||
resp, err := cl.Get("http://127.0.0.1:8888/healthz")
|
||
if err != nil {
|
||
return false
|
||
}
|
||
defer resp.Body.Close()
|
||
return resp.StatusCode == http.StatusOK
|
||
}
|