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 ./... 干净。
This commit is contained in:
JianFeeeee
2026-09-25 17:57:15 +08:00
parent 35aeb6f88b
commit 4dcbdb1269
6 changed files with 192 additions and 23 deletions

View File

@ -67,7 +67,15 @@ var downloadClient = &http.Client{
},
}
var HTTPAddr = "127.0.0.1:9876" // 监听地址,可被 settings 配置
// defaultHTTPAddr 是 HTTP API 的**内置默认**监听地址。
//
// ★ 曾经这里是一个**包级可变全局** `var HTTPAddr`,且 Start() 会把 settings 读到的值
// **反写**回该全局。两个真实后果:
// 1. 多实例互相污染——测试并行起两个 Registry,后启动的实例会把地址写进全局,
// 先启动那个的 startHTTPServer 读到的是别人的地址(实测与生产 homed 抢 9876);
// 2. 全局读写在并发下没有同步,属数据竞态。
// 现在改为实例字段 p.httpAddr(默认值走本常量),不再有可被任意代码改写的包级状态。
const defaultHTTPAddr = "127.0.0.1:9876"
func init() {
plugin.RegisterPluginMeta("pluginmgr", "插件管理", "Plugin Manager")
@ -83,12 +91,13 @@ type Plugin struct {
mux *http.ServeMux
listen net.Listener
httpURL string
httpAddr string // 本实例的监听地址(默认 defaultHTTPAddr;来自 settings)
sdk *sdk.PluginSDK
pluginDir string
}
func New(name string) *Plugin {
return &Plugin{name: name, mux: http.NewServeMux()}
return &Plugin{name: name, mux: http.NewServeMux(), httpAddr: defaultHTTPAddr}
}
func (p *Plugin) Name() string { return p.name }
@ -98,16 +107,19 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
p.sdk = s
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "http_addr",
Default: HTTPAddr,
Default: defaultHTTPAddr,
Type: "string",
DisplayName: "HTTP 监听地址",
Description: "插件管理 API 的监听地址,设为空可禁用 HTTP 服务",
Category: "pluginmgr",
Description: "插件管理 API 的监听地址,设为空可禁用 HTTP 服务;" +
"填 127.0.0.1:0 让系统分配空闲端口(测试/多实例推荐)",
Category: "pluginmgr",
})
// 只写本实例字段,**不写任何包级状态**(见 defaultHTTPAddr 注释)。
p.httpAddr = defaultHTTPAddr
if v, _ := s.Settings().Get("http_addr"); v != nil {
if addr, ok := v.(string); ok && addr != "" {
HTTPAddr = addr
if addr, ok := v.(string); ok {
p.httpAddr = addr // 允许空串 = 显式禁用 HTTP 服务
}
}
@ -119,7 +131,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
p.registerTools(s)
if HTTPAddr != "" {
if p.httpAddr != "" {
p.startHTTPServer()
}
@ -273,23 +285,36 @@ func (p *Plugin) startHTTPServer() {
p.mux.HandleFunc("/plugins", p.handlePlugins)
p.mux.HandleFunc("/plugins/", p.handlePluginByID)
listen, err := net.Listen("tcp", HTTPAddr)
listen, err := net.Listen("tcp", p.httpAddr)
if err != nil {
log.Printf("[pluginmgr] HTTP listen: %v", err)
return
}
// 用**实际绑定**的地址而非配置值:配 :0 时只有 net.Listener 知道真实端口。
// 这也让 httpURL 在多实例/测试下始终指向本实例真正监听的端点。
url := "http://" + listen.Addr().String()
p.mu.Lock()
p.listen = listen
p.httpURL = "http://" + listen.Addr().String()
p.httpURL = url
p.mu.Unlock()
p.server = &http.Server{Handler: p.mux}
go func() {
log.Printf("[pluginmgr] HTTP API on %s", p.httpURL)
log.Printf("[pluginmgr] HTTP API on %s", url)
if err := p.server.Serve(listen); err != nil && err != http.ErrServerClosed {
log.Printf("[pluginmgr] HTTP serve: %v", err)
}
}()
}
// HTTPURL 返回本实例实际监听的基地址(形如 http://127.0.0.1:9876);
// 未启动或禁用时返回空串。供诊断与需要知道“到底在哪个端口”的调用方使用。
func (p *Plugin) HTTPURL() string {
p.mu.Lock()
defer p.mu.Unlock()
return p.httpURL
}
func (p *Plugin) handlePlugins(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet: