Files
homeagent-sdk/example/deepsearch/searxng.go
JianFeeeee 7c0b7a1fb0 feat(deepsearch): 联网检索插件 + SearXNG 生命周期托管
把原 websearch 示例改名为 deepsearch(目录/go.mod/plg.json/工具前缀/README 全量对齐,
工具名 websearch_* → deepsearch_*)。

新增 searxng.go:插件自己托管搜索后端
- 启动探 healthz:已在跑则直接接管(不重启),没跑就 docker compose up -d 并等就绪
- 关闭动作注册为 stop handler(幂等、限时 4s < 内核 5s 宽限期)
- 配置 manage_searxng / searxng_dir / stop_searxng_on_exit
- 崩溃/被 kill 时不关后端(下次启动接管):安全失败方向
- 可注入 cmdRunner + 时间预算,8 项单测离线覆盖接管/拉起/失败/幂等/保留

契约依据(internal/plugin/proc):停止插件 = plugin.stop → RunStopHandlers(LIFO、
幂等)→ Stop() → exit(0),宽限期 5s;stdin 关闭同路径。

验证:19 项单测(18 通过 + 1 联调跳过)、-race 干净、vet/gofmt 干净;内核 E2E 真调用
返回 58 条/37 条、58/58 带摘要;线上 daemon 实测 stop handler 与冷启动(约 3s)。
2026-09-12 23:16:23 +08:00

160 lines
5.0 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 余量。
// 若插件是被 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)
p.markSearxOwned()
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)
}