feat(backthink,browser): 自排工具面 C1-3 + 浏览器两处偏离收口(X2+X3 同文件一并落)
X2 C1-3(自排工具面与提示词): - request_think(thinktool.go,照 transcode/tool.go 范式)+ reason 必填/urgency 闭集 + 双守门 (安全闸 + owner 判定:群聊/陌生人一律拒) - **成对**进两张唤醒态允许集(quiet + awake;CAPACITY_TOOL_SETS 是整体替换) - 提示词抽共用段落 + 自排前缀优先(不枚举工具清单);面板加 self_wake 标签与筛选轴 - 4 个变异自检(拿掉 owner 判定/只加一张表/放宽 urgency/前端标签恒等) X3 收口(与 X2 共用 wire_services.go ⇒ 一并落): - 删掉第三份 SSRF 实现(guard.go),改由 io.ValidateRequestURL 唯一判定 + 薄门面(保两个错误身份) - 浏览器会话挂进 a.close(真机:真 Chrome 被关、无残留、生产未扰动) - a.wireBrowser() 归位到 wire_runtime.go,删掉 ioToolOptions 的临时挂点 - 5 个变异自检 顺带补提交 C1-2 漏掉的 internal/config/runtimeconfig/backthink_test.go(ed50053 漏 add)。
This commit is contained in:
1 parent
2bf3b536dd
commit
2d211c2d16
20 files changed
+1568
-176
No files matched your search
@@ -22,10 +22,11 @@ package main
|
||||
// 增强**,磁盘上没有 Chrome 的机器照样得把整个进程拉起来。
|
||||
// 6. **幂等**:重复装配只发现工具已在注册表里并跳过,不重复注册、不重复探测。
|
||||
//
|
||||
// 关于挂点:组合根在启动期只调用 `ioToolOptions()`(wire_runtime.go)这一个归属本片
|
||||
// 的装配函数,而本片不得改他人拥有的 `wire_runtime.go` / `wire.go`
|
||||
// ⇒ browser 的接线由 `wire_io_tools.go` 的 `ioToolOptions()` 转交给本文件的
|
||||
// `wireBrowser()`(那一处有同样的说明)。
|
||||
// 关于挂点(切片 X3 归位):本文件的 `wireBrowser()` 由 `wireRuntime` 调用,位置与
|
||||
// `wireVision()` / `wireTranscode()` 并列 —— 三者同类(可选增强,不可用就不注册)。
|
||||
// 它**不再**借 `ioToolOptions()` 转交:那件函数是「组装 io 选项」的只读装配,顺带探测
|
||||
// 并注册浏览器会让所有只为要一份选项而调它的路径(web-search 的接线测试就是)连带起
|
||||
// 一次浏览器探测。它的关闭挂点在 `a.close`(wire_services.go),与这一行成对。
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
package main
|
||||
|
||||
// 本文件钉住 `browser` 工具的**生命周期两端**(切片 X3 的收口):
|
||||
//
|
||||
// - **装配端**:接线挂点在 `wireRuntime`(与 wireVision / wireTranscode 并列),
|
||||
// **不再**借 `ioToolOptions()` 转交 —— 挂点是源码级事实,只有读源码才钉得住,
|
||||
// 因此这里照 wire_channels_test.go 的既有做法断言那一行。
|
||||
// - **关闭端**:`browser` 工具持有**进程外**资源(我们起的真 Chromium 子进程 + CDP
|
||||
// 连接 + profile 锁),进程退出不会带走它(Windows 上父进程死不会杀子进程)。
|
||||
// 常驻用例证明「关闭链真的走到了那一把工具背后的管理器」;`CYRENE_BROWSER_E2E=1`
|
||||
// 的真机用例进一步证明「真 Chrome 的调试端点真的不再应答」。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
browsercap "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browser"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browsercfg"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
// TestWireRuntime_CallsWireBrowser 钉住**挂点归位**(切片 X3):browser 的接线由
|
||||
// `wireRuntime` 调用,位置与 wireVision / wireTranscode 并列。
|
||||
//
|
||||
// 为什么必须钉:这条调用是「浏览器工具在不在注册面里」的唯一开关,而它删掉之后
|
||||
// **没有任何运行期症状** —— 编译照过、启动照起、日志照打,只有模型那边少了一个工具
|
||||
// (本机有 Chrome 也不会注册)。这与 wire_channels_test.go 钉 VoiceTranscoder 注入
|
||||
// 是同一类判据:能力静默消失只能靠测试拦。
|
||||
func TestWireRuntime_CallsWireBrowser(t *testing.T) {
|
||||
t.Parallel()
|
||||
src := readSource(t, "wire_runtime.go")
|
||||
if !strings.Contains(src, "\ta.wireBrowser()") {
|
||||
t.Fatal("wire_runtime.go 里找不到 `a.wireBrowser()` 这一行 —— browser 工具不会被装配," +
|
||||
"模型面里静默少一个工具(挂点应与之并列 wireVision / wireTranscode)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppClose_ClosesBrowserSession 钉住**关闭链真的关掉了浏览器管理器**。
|
||||
//
|
||||
// 判据不是「Close 被调过」(那只能证明调用点存在),而是**关闭之后的第一个动作**:
|
||||
// 管理器已进入 closed 态 ⇒ 如实报 ErrNoSession,而不是偷偷再拉起一个浏览器
|
||||
// (拉起会得到 ErrLaunch,两者一眼可分)。
|
||||
func TestAppClose_ClosesBrowserSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := wsLoadConfig(t)
|
||||
// 可执行文件只需**存在**:本用例不启动它,只验证关闭链把管理器关停了。
|
||||
exe := filepath.Join(t.TempDir(), "fake-browser.exe")
|
||||
if err := os.WriteFile(exe, []byte("x"), 0o700); err != nil {
|
||||
t.Fatalf("造假可执行文件: %v", err)
|
||||
}
|
||||
raw, _ := json.Marshal(map[string]any{"executable": exe})
|
||||
writeBrowserConfig(t, cfg, string(raw))
|
||||
var out bytes.Buffer
|
||||
a := browserTestApp(t, cfg, &out)
|
||||
|
||||
a.wireBrowser()
|
||||
cap, ok := browserEntry(a)
|
||||
if !ok {
|
||||
t.Fatalf("可用时 browser 应进注册面(日志:%s)", out.String())
|
||||
}
|
||||
tool, ok := cap.Value.(sdk.Tool)
|
||||
if !ok {
|
||||
t.Fatalf("注册的值不是 sdk.Tool: %T", cap.Value)
|
||||
}
|
||||
|
||||
a.close(context.Background())
|
||||
|
||||
if !strings.Contains(out.String(), "browser tool closed") {
|
||||
t.Errorf("关闭链没有留下收尾记录(日志:%s)", out.String())
|
||||
}
|
||||
if _, err := tool.Execute(context.Background(), map[string]any{"action": "info"}); !errors.Is(err, browsercap.ErrNoSession) {
|
||||
t.Fatalf("关闭链没有关掉浏览器管理器:err = %v,want ErrNoSession(ErrLaunch 说明它又拉起了一个)", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppClose_RealChromeStopsOnShutdown 是真机端到端(默认 Skip):走**组合根的真实
|
||||
// 关闭链**关掉一个**真的在跑**的 Chrome,并验证它的调试端点不再应答。
|
||||
//
|
||||
// 用法(Windows / Git Bash):
|
||||
//
|
||||
// CYRENE_BROWSER_E2E=1 go test ./cmd/cyrene/ -run TestAppClose_RealChromeStopsOnShutdown -v -timeout 5m
|
||||
//
|
||||
// 为什么默认 Skip:它会真的起一个浏览器(headless,不弹窗),属于「人工按需复跑」,
|
||||
// 不该混进普通 `go test`(AGENTS §10 的压测纪律:验收里不无界占机)。
|
||||
func TestAppClose_RealChromeStopsOnShutdown(t *testing.T) {
|
||||
if !browserE2EEnabled() {
|
||||
t.Skip("未设 CYRENE_BROWSER_E2E=1:真机端到端默认不跑(用法见本文件顶部)")
|
||||
}
|
||||
exe, ok := browsercfg.NewProber().FindLocalBrowser()
|
||||
if !ok {
|
||||
t.Skip("本机没有 Chrome/Chromium,真机 e2e 无法进行")
|
||||
}
|
||||
cfg := wsLoadConfig(t)
|
||||
raw, _ := json.Marshal(map[string]any{
|
||||
"executable": exe,
|
||||
"headless": true,
|
||||
"timeoutSeconds": 60,
|
||||
"idleTimeoutSeconds": 600,
|
||||
})
|
||||
writeBrowserConfig(t, cfg, string(raw))
|
||||
var out bytes.Buffer
|
||||
a := browserTestApp(t, cfg, &out)
|
||||
|
||||
a.wireBrowser()
|
||||
cap, ok := browserEntry(a)
|
||||
if !ok {
|
||||
t.Fatalf("可用时 browser 应进注册面(日志:%s)", out.String())
|
||||
}
|
||||
tool, ok := cap.Value.(sdk.Tool)
|
||||
if !ok {
|
||||
t.Fatalf("注册的值不是 sdk.Tool: %T", cap.Value)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
// ① 起一个真浏览器(指纹自检走 CDP,无需外网),并从 profile 读回它的调试端点。
|
||||
if _, err := tool.Execute(ctx, map[string]any{"action": "info"}); err != nil {
|
||||
t.Fatalf("action=info(起真浏览器): %v", err)
|
||||
}
|
||||
endpoint := devToolsEndpoint(t, browsercfg.DefaultProfileDir(cfg.DataDir))
|
||||
if !endpointAlive(endpoint) {
|
||||
t.Fatalf("会话已建立但调试端点 %s 不应答:本用例的前提不成立", endpoint)
|
||||
}
|
||||
t.Logf("真机前置:浏览器在跑,endpoint=%s", endpoint)
|
||||
|
||||
// ② 走生产路径关闭:组合根的 a.close(不是直接 tool.Close)。
|
||||
shutCtx, shutCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer shutCancel()
|
||||
a.close(shutCtx)
|
||||
|
||||
// ③ 进程外的那个浏览器必须真的没了:调试端点不再应答(轮询给一点收尾时间)。
|
||||
if !waitEndpointGone(endpoint, 15*time.Second) {
|
||||
t.Fatalf("a.close 之后 %s 仍在应答:浏览器没被关掉(孤儿 Chrome 会占着 profile 锁)", endpoint)
|
||||
}
|
||||
// ④ 管理器进入 closed 态:再调动作如实报「已关闭」,不是偷偷重起一个。
|
||||
if _, err := tool.Execute(context.Background(), map[string]any{"action": "info"}); !errors.Is(err, browsercap.ErrNoSession) {
|
||||
t.Errorf("关闭后 err = %v, want ErrNoSession", err)
|
||||
}
|
||||
t.Logf("真机关闭链:endpoint=%s 已停,管理器态=closed(日志尾部:%s)",
|
||||
endpoint, tailOfLog(out.String()))
|
||||
}
|
||||
|
||||
// devToolsEndpoint 从 profile 目录的 DevToolsActivePort 读出这次会话的调试端点
|
||||
// (格式与 launch.go 的 waitDevToolsPort 同源:首行是端口号)。
|
||||
func devToolsEndpoint(t *testing.T, profileDir string) string {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join(profileDir, "DevToolsActivePort"))
|
||||
if err != nil {
|
||||
t.Fatalf("读 DevToolsActivePort: %v(profile=%s)", err, profileDir)
|
||||
}
|
||||
line, _, _ := strings.Cut(strings.TrimSpace(string(raw)), "\n")
|
||||
port, err := strconv.Atoi(strings.TrimSpace(line))
|
||||
if err != nil || port <= 0 {
|
||||
t.Fatalf("DevToolsActivePort 内容不合法: %q", raw)
|
||||
}
|
||||
return fmt.Sprintf("http://127.0.0.1:%d", port)
|
||||
}
|
||||
|
||||
// endpointAlive 报告调试端点是否在应答(一次 /json/version)。
|
||||
func endpointAlive(endpoint string) bool {
|
||||
client := &http.Client{Timeout: 2 * time.Second}
|
||||
resp, err := client.Get(endpoint + "/json/version")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
return resp.StatusCode == http.StatusOK
|
||||
}
|
||||
|
||||
// waitEndpointGone 等端点停止应答,最多等 budget。
|
||||
func waitEndpointGone(endpoint string, budget time.Duration) bool {
|
||||
deadline := time.Now().Add(budget)
|
||||
for {
|
||||
if !endpointAlive(endpoint) {
|
||||
return true
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return false
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// tailOfLog 取日志末尾若干字符,供真机用例把现场证据打进 t.Log。
|
||||
func tailOfLog(s string) string {
|
||||
const keep = 400
|
||||
if len(s) <= keep {
|
||||
return s
|
||||
}
|
||||
return s[len(s)-keep:]
|
||||
}
|
||||
@@ -177,9 +177,14 @@ func TestWireBrowser_ProfileInGitTreeIsRejected(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestIOToolOptions_WiresBrowserExactlyOnce 钉住**挂点与幂等**:browser 的接线挂在
|
||||
// `ioToolOptions()`(组合根启动期唯一归属本片的装配入口)上,且重复调用不重复注册。
|
||||
func TestIOToolOptions_WiresBrowserExactlyOnce(t *testing.T) {
|
||||
// TestIOToolOptions_DoesNotWireBrowser 钉住**临时挂点已删**(切片 X3 把挂点归位到
|
||||
// `wireRuntime`):browser 的接线原先借 `ioToolOptions()` 转交(V5 改不了
|
||||
// wire_runtime.go 时的权宜),代价是「取一份 io 选项」这件只读的事会顺带探测并注册
|
||||
// 浏览器工具 —— 所有只为要选项而调本函数的路径(web-search 的接线测试就是)都会连带
|
||||
// 起一次浏览器探测。归位后的挂点在 wire_runtime.go,由
|
||||
// TestWireRuntime_CallsWireBrowser 钉住;这里钉住 io 侧**不再有第二个挂点** ——
|
||||
// 两处并存不会报错(函数幂等),只会静默多一次探测,因此只有测试能拦住它回流。
|
||||
func TestIOToolOptions_DoesNotWireBrowser(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := wsLoadConfig(t)
|
||||
exe := filepath.Join(t.TempDir(), "fake-browser.exe")
|
||||
@@ -192,11 +197,32 @@ func TestIOToolOptions_WiresBrowserExactlyOnce(t *testing.T) {
|
||||
a := browserTestApp(t, cfg, &out)
|
||||
|
||||
a.ioToolOptions()
|
||||
|
||||
if _, ok := browserEntry(a); ok {
|
||||
t.Fatalf("ioToolOptions 不该再挂 browser(挂点已归位 wire_runtime.go;日志:%s)", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestWireBrowser_Idempotent 钉住**幂等**:同一个 app 上重复装配只发现工具已在注册表里
|
||||
// 并跳过,不重复注册、不重复探测。挂点归位后幂等仍是唯一的保险(两条路径万一偶遇)。
|
||||
func TestWireBrowser_Idempotent(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := wsLoadConfig(t)
|
||||
exe := filepath.Join(t.TempDir(), "fake-browser.exe")
|
||||
if err := os.WriteFile(exe, []byte("x"), 0o700); err != nil {
|
||||
t.Fatalf("造假可执行文件: %v", err)
|
||||
}
|
||||
raw, _ := json.Marshal(map[string]any{"executable": exe})
|
||||
writeBrowserConfig(t, cfg, string(raw))
|
||||
var out bytes.Buffer
|
||||
a := browserTestApp(t, cfg, &out)
|
||||
|
||||
a.wireBrowser()
|
||||
first, ok := browserEntry(a)
|
||||
if !ok {
|
||||
t.Fatalf("ioToolOptions 应当把 browser 接上(日志:%s)", out.String())
|
||||
t.Fatalf("可用时 browser 应进注册面(日志:%s)", out.String())
|
||||
}
|
||||
a.ioToolOptions()
|
||||
a.wireBrowser()
|
||||
second, ok := browserEntry(a)
|
||||
if !ok {
|
||||
t.Fatal("第二次调用后 browser 不该消失")
|
||||
|
||||
@@ -81,13 +81,12 @@ const webSearchToolName = "web-search"
|
||||
// ioToolOptions 组装 io 工具集的全部选项。wire_runtime 把它原样交给 iotools.New,
|
||||
// 因此「注册面里有哪些 io 工具」只由这一处决定。
|
||||
//
|
||||
// 它**同时是切片 V5 的 browser 接线挂点**:组合根在启动期只调用这一个归属本片的装配
|
||||
// 函数(wire_runtime.go 的 `a.ioToolOptions()`),而 browser 的接线不得改他人拥有的
|
||||
// `wire_runtime.go` / `wire.go` ⇒ 由这里转交给 `wireBrowser()`(见 wire_browser.go 的
|
||||
// 口径说明)。转交对 io 选项**零影响**(它不改、不追加任何 iotools.Option),并且
|
||||
// **幂等**:同一个 app 上重复调用只会发现工具已注册并跳过。
|
||||
// 它**只做 io 选项**(切片 X3 的收口):browser 的接线挂点原先是借这里转交的
|
||||
// (V5 当时改不了 wire_runtime.go),现已归位到 `wireRuntime` 里与 `wireVision` /
|
||||
// `wireTranscode` 并列的那一行(见 wire_runtime.go)。转交留下的第二个代价是副作用:
|
||||
// 凡是只为了要一份 iotools.Option 而调用本函数的路径(例如 web-search 的接线测试)
|
||||
// 都会顺带探测并注册浏览器工具 —— 那不该是「取选项」这件事的含义。
|
||||
func (a *app) ioToolOptions() []iotools.Option {
|
||||
a.wireBrowser()
|
||||
opts := []iotools.Option{
|
||||
iotools.WithKnowledgeRetriever(a.retriever),
|
||||
iotools.WithLogger(a.log),
|
||||
|
||||
@@ -177,6 +177,11 @@ func (a *app) wireRuntime() error {
|
||||
}
|
||||
a.wireVision()
|
||||
a.wireTranscode()
|
||||
a.wireBrowser()
|
||||
// 浏览器工具(design/28 B1 接线,切片 V5;挂点由 X3 归位到这里):
|
||||
// 与 vision / transcode 同族 —— 可选增强,不可用就**不注册**(模型看不见这个
|
||||
// 工具,而不是看得见、一调就报错),失败不阻塞启动;装配细节与六条口径都在
|
||||
// wire_browser.go 一处。它的关闭挂点在 a.close(wire_services.go),成对。
|
||||
|
||||
// 上下文溢出压缩:预算按模型窗口推导(CONTEXT_MAX_TOKENS 未设置时;
|
||||
// 显式 >0 = 覆盖、0/负 = 关闭),推导与「窗口未声明」的 WARN 都在
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -22,6 +23,7 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/automations"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/backthink"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
browsertools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/browser"
|
||||
)
|
||||
|
||||
// wireAuxiliary 组装执行提供者、任务管理器、IoT 管理器、渠道会话管理器、
|
||||
@@ -96,9 +98,38 @@ func (a *app) wireAuxiliary() error {
|
||||
// capability_list 工具。排在最后,是为了让它列出的清单已经包含前面各步
|
||||
// 注册进来的工具(terminal / subagent / session_read 等)。
|
||||
a.wireCapabilitySurface()
|
||||
// design/12 §N C.3:把「自排一次思考」暴露成能力注册表里的 request_think 工具。
|
||||
// 排在 wireCapabilitySurface 之后无妨 —— 清单是调用时实时读注册表的,顺序不影响
|
||||
// 它列出谁。
|
||||
a.wireSelfWakeTool()
|
||||
return nil
|
||||
}
|
||||
|
||||
// wireSelfWakeTool 把 request_think 注册进能力面(design/12 §N C.3):
|
||||
// `a.dispatcher.Register(backthink.NewThinkTool(a.backthink))`。
|
||||
//
|
||||
// 时序安全:a.backthink 在 wireAuxiliary 上半段(本函数里)就建好了,而 a.loop /
|
||||
// a.dispatcher 在更早的 wireRuntime 已就位 ⇒ 这一步不需要新的装配顺序。
|
||||
//
|
||||
// 与 capability_list 同口径:**失败只告警、绝不阻塞启动** —— 少一个工具不是启动失败
|
||||
// 的理由(自排的其余面:判定、闸、审计都已在 backthink 内部就绪)。
|
||||
func (a *app) wireSelfWakeTool() {
|
||||
log := logger.WithSource(a.log, "services/backthink")
|
||||
if a.backthink == nil {
|
||||
log.Warn("self wake tool register skipped: no backthink service")
|
||||
return
|
||||
}
|
||||
if a.dispatcher == nil {
|
||||
log.Warn("self wake tool register skipped: no dispatcher")
|
||||
return
|
||||
}
|
||||
if err := a.dispatcher.Register(backthink.NewThinkTool(a.backthink)); err != nil {
|
||||
log.Warn("self wake tool register skipped", "error", err)
|
||||
return
|
||||
}
|
||||
log.Info("self wake tool registered", "tool", backthink.ThinkToolName)
|
||||
}
|
||||
|
||||
// buildSkillFace 构造技能面(SK-5,design/09 §6.3):来源是能力注册表里的
|
||||
// KindSkill 条目 —— 启动时 wireSkills 载入的与面板热装的技能都在同一处,且
|
||||
// **每轮实时读取**,因此热装技能下一轮就出现在模型清单里,无需重启。
|
||||
@@ -197,6 +228,34 @@ func (a *app) close(ctx context.Context) {
|
||||
if a.gw != nil {
|
||||
a.gw.Close()
|
||||
}
|
||||
// 浏览器会话(切片 X3 把 V5 留下的挂点接在这里):`browser` 工具持有**进程外**
|
||||
// 资源 —— 我们起的真 Chromium 子进程、一条 CDP 连接、profile 上的 SingletonLock,
|
||||
// 因此必须显式关掉,而不是交给进程退出。
|
||||
//
|
||||
// 位置的三条理由(顺序在这里是有含义的):
|
||||
// 1. **排在渠道 / 调度器 / 网关之后**:这三步停掉的是「还会发起工具调用的入口」
|
||||
// (IM → 子代理 → Web),此刻浏览器不会再从某个正在跑的回合底下被抽走;
|
||||
// 2. **排在 MCP / 插件 / 任务之前**:它属于能力面,与那几步同类,且不碰存储
|
||||
// (db / bus 都在后面),提前关不影响任何后续关停;
|
||||
// 3. **绝不能拖到进程退出以后**:Windows 上父进程退出不会带走子进程,孤儿 Chrome
|
||||
// 会继续占着 profile 锁,下次启动就得靠「认领」绕过去 —— 早一行关掉,孤儿就
|
||||
// 少一分。`Manager.Close` 自带超时(不等 shutCtx),所以这一步不会被关停预算卡住。
|
||||
//
|
||||
// 工具对象没有单独的 app 字段(能力注册面就是它的家),因此按工具名取回:没注册
|
||||
// (本机没浏览器)就是空操作,关闭链不因它存在与否改道。
|
||||
if a.caps != nil {
|
||||
if entry, err := a.caps.Get(browsertools.ToolName); err == nil {
|
||||
if closer, ok := entry.Value.(io.Closer); ok {
|
||||
if cerr := closer.Close(); cerr != nil {
|
||||
logger.WithSource(a.log, "cmd/cyrene").Warn("browser tool close failed", "error", cerr)
|
||||
} else {
|
||||
// 一行可追溯的收尾记录:排障时「退出后还有没有残留的浏览器」看的
|
||||
// 就是它(孤儿 Chrome 会占着 profile 锁,下一次启动要靠认领绕过去)。
|
||||
logger.WithSource(a.log, "cmd/cyrene").Info("browser tool closed")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, reg := range a.mcpRegs {
|
||||
_ = reg.Unregister(ctx)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user