Files
HomeAgent/internal/plugins/pluginmgr/addr_isolation_test.go
JianFeeeee 17e7094967 fix(plugins): 修三处端口/监听缺陷 + 让测试用临时端口(消除既有 flaky)
排查内核 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 ./... 干净。
2026-09-25 17:57:15 +08:00

50 lines
1.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package pluginmgr
import (
"net"
"strings"
"testing"
)
// TestTwoInstances_ListenIndependently 是「监听地址必须是实例状态」的**端到端**判据。
//
// 为什么不能只断言字段不共享:那是实现细节,且容易被「变异后仍通过」的弱测试骗过
// (我第一版就写了那样的测试,变异证明它没有牙)。真正的性质是:
//
// 两个实例能**同时**成功监听,且各自 HTTPURL 指向自己那个端口。
//
// 曾经的缺陷(包级可变全局 `var HTTPAddr` + Start() 反写它 + startHTTPServer 读它)
// 恰好会违背这一点:两个实例共用同一个地址 → 第二个 Listen 报
// `bind: address already in use`,实测在生产机上与 homed 抢 9876。
//
// 用 `127.0.0.1:0` 让 OS 分配端口,避免测试自身依赖任何固定端口。
func TestTwoInstances_ListenIndependently(t *testing.T) {
a, b := New("a"), New("b")
a.httpAddr, b.httpAddr = "127.0.0.1:0", "127.0.0.1:0"
a.startHTTPServer()
b.startHTTPServer()
t.Cleanup(func() {
_ = a.Stop()
_ = b.Stop()
})
ua, ub := a.HTTPURL(), b.HTTPURL()
if ua == "" || ub == "" {
t.Fatalf("实例未成功监听:a=%q b=%q(包级全局会让第二个 bind 失败)", ua, ub)
}
if ua == ub {
t.Fatalf("两个实例报出同一个地址 %q —— 监听地址被共享了,不是实例状态", ua)
}
// 两个地址都必须**真的可连**(不能只报一个字符串)
for name, u := range map[string]string{"a": ua, "b": ub} {
addr := strings.TrimPrefix(u, "http://")
ln, err := net.Dial("tcp", addr)
if err != nil {
t.Fatalf("实例 %s 报的地址 %s 不可连: %v", name, addr, err)
}
ln.Close()
}
}