Files
homeagent-sdk/example/deepsearch/searxng.go
JianFeeeee e50bffa34f fix(deepsearch): 只关自己拉起的 SearXNG;条数改由插件侧截断
两个都由「真调用/真测试」暴露,且都会让线上搜索表现为「后端不可用」。

一、归属:接管不等于拥有(例:E2E 测试把生产后端带走)
  旧实现只要探活成功就认领关闭责任 → 同机第二个实例(测试拉起的插件、另一个 daemon)
  退出时就 docker compose stop 掉**线上正在用的**后端。实测:跑一次
  `go test ./internal/plugins/ -run TestRealPlugin_DeepSearch`,teardown 即关停
  127.0.0.1:8888,用户看到的就是「搜索后端起不来」。
  修:只有真正执行过 `docker compose up -d` 的实例才算「我们起的」;探到已在运行只接管。

二、条数:SearXNG 不认 count/limit(count/max_results 形同虚设)
  实测 ?count=3、?limit=3、不带参数返回**完全相同的 35 条**,所以截断必须在插件里做。
  旧实现把 count 当 limit 参数发给 SearXNG 就以为生效了 → 模型每次吞 35~58 条带摘要结果,
  还会把「命中 N 条」当成「拿到了 N 条」报给用户(实测发生过)。
  修:新增 limitResults(默认取 max_results,上限 20);输出改成
  「命中 N 条,返回前 M 条」;不再发无意义的 limit 参数。

验证:22 项单测全过、-race 干净、vet/gofmt 干净;两条归属测试做过扰动(把旧语义放回
去后必红,并如实打出它执行的 `docker compose stop -t 2`);内核 E2E 三条通过且
**跑完 healthz 仍 200、容器未重启**;线上 1.1.2 实测 count=3 → 「命中 40 条,返回前 3 条」。

版本 1.1.0 → 1.1.2。
2026-09-13 09:55:27 +08:00

165 lines
5.5 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 main
// SearXNG 生命周期托管:插件启动时拉起搜索后端,插件停止时关闭它。
//
// 契约依据(内核侧 internal/plugin/proc/*,已逐行核对):
// - 内核停止插件:发 `plugin.stop` → 插件先跑 RunStopHandlersLIFO、幂等→ 再 Stop() → exit(0)
// - 若插件未在 stopGracePeriod**5 秒**)内退出,内核直接 SIGKILL
// - stdin 关闭(内核消失)同样会跑 handlers + Stop()
//
// 因此这里的关闭动作必须**有界**searxShutdownBudget 取 4s留 1s 余量。
//
// 归属规则(谁拉起谁关):**只有本插件真正执行了 `docker compose up -d` 的实例才算「我们起的」**。
// 探活发现已在运行的实例只「接管」——不认领关闭责任。否则同一台机器上的第二个实例
// E2E 测试拉起的插件、另一个 daemon退出时会把生产后端一起带走实测就是这条把
// 线上搜索服务反复关停的(测试实例用默认配置,测试结束就 `docker compose stop`)。
// 若插件是被 kill -9 / OOM 带走的,关闭动作不会执行 —— SearXNG 会留在运行态;
// 下次 Start 探测到它在跑就直接接管,这是更安全的失败方向。
import (
"context"
"log"
"net/http"
"os/exec"
"time"
)
const (
cfgManageSearx = "manage_searxng"
cfgSearxDir = "searxng_dir"
cfgStopOnExit = "stop_searxng_on_exit"
defaultSearxDir = "/root/searxng-agent"
searxProbeTimeout = 1500 * time.Millisecond // 单次 healthz 探测
searxUpBudget = 20 * time.Second // docker compose up -d 的上限(正常 1s 内返回)
searxReadyBudget = 6 * time.Second // up 之后等 healthz 就绪的上限
searxShutdownBudget = 4 * time.Second // 必须 < 内核 5s 宽限期
)
// searxBudget 把四个时间预算收拢,便于单测注入短值(否则测试要真等就绪窗口)。
type searxBudget struct {
probe time.Duration
up time.Duration
ready time.Duration
shutdown time.Duration
}
func (p *Plugin) budget() searxBudget {
b := p.bud
if b.probe == 0 {
b.probe = searxProbeTimeout
}
if b.up == 0 {
b.up = searxUpBudget
}
if b.ready == 0 {
b.ready = searxReadyBudget
}
if b.shutdown == 0 {
b.shutdown = searxShutdownBudget
}
return b
}
// cmdRunner 抽出来是为了让生命周期逻辑可单测:注入假执行器,不起真容器。
type cmdRunner func(ctx context.Context, dir, name string, args ...string) (string, error)
func defaultRunner(ctx context.Context, dir, name string, args ...string) (string, error) {
cmd := exec.CommandContext(ctx, name, args...)
cmd.Dir = dir
out, err := cmd.CombinedOutput()
return string(out), err
}
// searxReachable 探测搜索后端是否可用(只看 healthz不发检索请求
func (p *Plugin) searxReachable(timeout time.Duration) bool {
if p.searxURL == "" {
return false
}
base := p.http
if base == nil {
base = &http.Client{}
}
cl := *base // 复制一份,避免改到共享 client 的超时
cl.Timeout = timeout
req, err := http.NewRequest(http.MethodGet, p.searxURL+"/healthz", nil)
if err != nil {
return false
}
req.Header.Set("User-Agent", p.userAgent)
resp, err := cl.Do(req)
if err != nil {
return false
}
defer resp.Body.Close()
return resp.StatusCode < 400
}
// ensureSearxng 在插件启动时确保搜索后端在跑;已在跑则直接接管,不重启。
func (p *Plugin) ensureSearxng() {
b := p.budget()
if !p.manageSearx {
log.Printf("[%s] 未启用 SearXNG 托管manage_searxng=false假定 %s 由外部维护", p.name, p.searxURL)
return
}
if p.searxReachable(b.probe) {
// 只接管,不认领:不是我们拉起来的,就不能由我们关掉
log.Printf("[%s] SearXNG 已在运行(%s直接接管不认领关闭责任", p.name, p.searxURL)
return
}
ctx, cancel := context.WithTimeout(context.Background(), b.up)
out, err := p.run(ctx, p.searxDir, "docker", "compose", "up", "-d")
cancel()
if err != nil {
log.Printf("[%s] 拉起 SearXNG 失败dir=%s请检查 manage_searxng/searxng_dir 配置): %v输出: %s",
p.name, p.searxDir, err, oneLine(out, 300))
return
}
log.Printf("[%s] 已执行 docker compose up -d%s%s", p.name, p.searxDir, oneLine(out, 200))
deadline := time.Now().Add(b.ready)
for time.Now().Before(deadline) {
if p.searxReachable(800 * time.Millisecond) {
log.Printf("[%s] SearXNG 就绪", p.name)
p.markSearxOwned()
return
}
time.Sleep(600 * time.Millisecond)
}
log.Printf("[%s] SearXNG 已启动但 %s 内未就绪;首次检索会自动等待", p.name, b.ready)
p.markSearxOwned()
}
func (p *Plugin) markSearxOwned() {
p.searxMu.Lock()
p.searxOwned = true
p.searxMu.Unlock()
}
// shutdownSearxng 关闭搜索后端。幂等,且有界(内核宽限期 5s这里最多 4s
func (p *Plugin) shutdownSearxng() {
b := p.budget()
p.searxMu.Lock()
owned := p.searxOwned
p.searxOwned = false
p.searxMu.Unlock()
if !owned {
return // 不是我们拉起来的 / 已经关过
}
if !p.manageSearx || !p.stopOnExit {
log.Printf("[%s] 保留 SearXNG 运行stop_searxng_on_exit=false", p.name)
return
}
ctx, cancel := context.WithTimeout(context.Background(), b.shutdown)
defer cancel()
out, err := p.run(ctx, p.searxDir, "docker", "compose", "stop", "-t", "2")
if err != nil {
// 故意只记日志:这里再重试就会拖过内核宽限期,被 SIGKILL 更糟
log.Printf("[%s] 关闭 SearXNG 失败(忽略): %v输出: %s", p.name, err, oneLine(out, 200))
return
}
log.Printf("[%s] 已关闭 SearXNG", p.name)
}