fix(browser): 同 profile 的浏览器还活着时不再必然启动超时
真机复现(我做的):同 profile 再起一次 chrome.exe ⇒ Chrome **把命令委托给已有 实例并 exit 0**,stderr 逐字「正在现有的浏览器会话中打开。」⇒ **不产生新的 DevToolsActivePort** ⇒ 我们的等待只能超时 ⇒ 浏览器降级档整体不可用。 修法:启动前加一跳**探测**,三档各自出路 —— - 端口记录缺席 / 端口没人听 ⇒ 正常启动(后者先把**陈记录**清掉:那是启动时写下 的记录、不是锁,删一条指向死端口的记录不会伤到任何活实例); - 端口在听 + `/json/version` 有合法响应 ⇒ **复用它**(INFO `browser debug endpoint reused`),**不再起第二个**; - 端口在听但握不上手 ⇒ **如实报错**(保留现场,既不起进程也不删记录); - 真被占用且拿不到端点 ⇒ 报错带 **Chrome 原话**(`withChromeOutput`)+ 可操作处置。 🔴 红线(写进错误文案与注释):**绝不杀别人的浏览器**、不发信号抢 profile、 不新开监听端口;`ErrLaunch` / `errors.Is` 口径未破。 验收(主会话复跑 + 亲自变异):browser 包与 plugins/tools/io 两包 build/vet/test 全绿;我用 -overlay 摘掉「复用」那一支 ⇒ TestLocalChromeAdoptsLiveEndpoint 与 TestDial_ReusesLiveEndpointAndDoesNotSpawnSecondProcess 变红。子代理另跑了真机 端到端(临时 profile):会话 1 拿端口 51732 → 裸起第二次 chrome(exit 0)→ 会话 2 **复用同一端口、未起第二个实例**。 ⚠️ 已知未修(如实):Chrome 那句委托提示是**控制台代码页(GBK)字节**,我们的 UTF-8 日志里会显示成乱码(字节保真、人看不了);彻底解决要引编码转换,另定。 ⚠️ 判据③的取舍:陈记录 +端口恰被别的程序复用时,会持续报错直到人工删 `DevToolsActivePort`(错误文案已写明处置);**未加"报错后自动清理重试"**。
This commit is contained in:
1 parent
93c5a4c46f
commit
7f63c5bec4
4 files changed
+470
-43
No files matched your search
@@ -0,0 +1,130 @@
|
|||||||
|
package browser
|
||||||
|
|
||||||
|
// 本文件是启动路径的**前置判定**:起进程之前先看「这个 profile 上是不是已经有一个浏览器」。
|
||||||
|
// 现场(主人实测复现):同一个 profile 上再起一次 Chrome 时,新进程 **exit 0**、不写新的
|
||||||
|
// DevToolsActivePort,stderr 只有一句「正在现有的浏览器会话中打开。」—— Chromium 把命令
|
||||||
|
// **委托给已在跑的那个实例**后自己退出。于是只按「等端口文件出现」实现的话,这一档永远等不到
|
||||||
|
// 端口:上一次会话只要没退干净,整个浏览器档就不可用。
|
||||||
|
//
|
||||||
|
// 三条判定与三条不同的出路(顺序即 Dial 里的调用顺序):
|
||||||
|
//
|
||||||
|
// ① 端点活着(端口在听 + `/json/version` 有合法响应)⇒ **复用它**,不再起第二个进程;
|
||||||
|
// ② 端口记录在、但端口没人听 ⇒ 那是**陈记录**,清掉后照常启动;
|
||||||
|
// ③ 端口在听却握不上手 ⇒ **如实报错**(带处置建议),不起进程 —— 绝不静默忽略。
|
||||||
|
//
|
||||||
|
// 红线:这一路只做**出站**连接,不杀任何进程、不发任何信号去抢 profile、不开任何监听端口
|
||||||
|
// (AGENTS §9);主人的浏览器可能正开着,我们只能连它或绕开它。
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
// probeExistingEndpoint 探「profile 目录里记的调试端点还能不能用」——启动路径的第一步。
|
||||||
|
// 三条结论各自对应一条**不同于启动**的出路:
|
||||||
|
//
|
||||||
|
// ("", nil) 没有可复用的端点:端口记录缺席,或记的端口**已经没人听**(陈文件)
|
||||||
|
// ⇒ 调用方清掉陈记录后正常启动(见 clearStalePortFile);
|
||||||
|
// (url, nil) 端口在听、且 `/json/version` 有**合法响应** ⇒ 复用它,不再起第二个进程;
|
||||||
|
// ("", err) 端口在听、但握手失败 ⇒ **如实报错**(launchMarkerEndpointBroken),不起进程。
|
||||||
|
//
|
||||||
|
// 为什么先独立做一次 TCP 探活(而不是把 HTTP 探活的失败一分为二):两条出路必须分开 ——
|
||||||
|
// 「没人听」是**陈文件**(可以安全清掉后照常启动),「在听却不答 CDP」是**占用/异常**(只能
|
||||||
|
// 如实报)。混成一句「探测失败 ⇒ 直接启动」,就会在真被占用时把一个必然「委托出去就退出」的
|
||||||
|
// 空壳进程当成本次启动,把原因退化成一句查不明白的「启动失败」。
|
||||||
|
//
|
||||||
|
// 探测超时给得很短(adoptProbeTimeout):这是「要不要复用」的判断,不该拖住启动路径。
|
||||||
|
func (l *localChrome) probeExistingEndpoint(ctx context.Context) (string, error) {
|
||||||
|
port, ok := readDevToolsPort(filepath.Join(l.profileDir, devToolsPortFile))
|
||||||
|
if !ok {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
if !portListening(ctx, port) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
endpoint := fmt.Sprintf("http://127.0.0.1:%d", port)
|
||||||
|
probeCtx, cancel := context.WithTimeout(ctx, adoptProbeTimeout)
|
||||||
|
defer cancel()
|
||||||
|
var version struct {
|
||||||
|
Browser string `json:"Browser"`
|
||||||
|
}
|
||||||
|
handshakeErr := httpGetJSON(probeCtx, endpoint+"/json/version", &version)
|
||||||
|
if handshakeErr == nil && version.Browser != "" {
|
||||||
|
return endpoint, nil
|
||||||
|
}
|
||||||
|
if handshakeErr == nil {
|
||||||
|
handshakeErr = errors.New("响应是合法 JSON,但没有 Browser 字段(不像 CDP 的 /json/version)")
|
||||||
|
}
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
// 探测期间调用方的预算先没了:这是「没时间了」,**不**说端点坏了。
|
||||||
|
return "", budgetLaunchErr(ctx)
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("%w: %s:%s 在听,但 /json/version 握手失败:%v。%s",
|
||||||
|
ErrLaunch, launchMarkerEndpointBroken, endpoint, handshakeErr, endpointBrokenHint(l.profileDir))
|
||||||
|
}
|
||||||
|
|
||||||
|
// portListening 报告 127.0.0.1:port 上有没有人在听(这一次探测的连接立刻关掉)。它只做
|
||||||
|
// **出站**连接,不开任何监听端口(AGENTS §9)。
|
||||||
|
func portListening(ctx context.Context, port int) bool {
|
||||||
|
d := net.Dialer{Timeout: adoptProbeTimeout}
|
||||||
|
conn, err := d.DialContext(ctx, "tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port)))
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_ = conn.Close()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// clearStalePortFile 清掉**上一轮**留下的端口记录 —— 只有确认「端口已经没人听」才会走到这里。
|
||||||
|
//
|
||||||
|
// 为什么必须清:`waitDevToolsPort` 只认这一个文件,上一轮留下的端口号会被当成**本次**的就绪
|
||||||
|
// 结果 —— 于是「这一次没启动成功」会伪装成「端点已就绪」,调用方拿到一个死端点。
|
||||||
|
//
|
||||||
|
// 为什么可以清:这个文件是浏览器启动时写下的**一条记录**,不是锁。真正在跑、真正拥有这个
|
||||||
|
// profile 的实例一定有一个**活着的**端口,那种情况上一步已经把它复用走了。因此删掉一条指向死
|
||||||
|
// 端口的记录,不可能从任何活着的实例手里拿走什么 —— 它影响的只有我们自己的就绪判定。
|
||||||
|
//
|
||||||
|
// 文件本来就不在(ErrNotExist)不算失败;别的错误(被占用 / 权限)**如实报**并且**不继续
|
||||||
|
// 启动** —— 留着一个陈记录继续启动,只会把陈端口读成本次结果,那正是本方法要防的事。
|
||||||
|
func (l *localChrome) clearStalePortFile() error {
|
||||||
|
path := filepath.Join(l.profileDir, devToolsPortFile)
|
||||||
|
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return fmt.Errorf("%w: 清掉上一轮的端口记录 %s 失败: %v(留着它会把陈端口当成本次就绪)",
|
||||||
|
ErrLaunch, path, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// withChromeOutput 把浏览器自己说的话附进**返回给调用方**的错误里。日志里本来就有它,但用户
|
||||||
|
// 侧看到的工具结果(`browserError`)来自这条错误 —— 不上报原话,用户就只看到我们这一侧的猜测
|
||||||
|
// (真机踩过:窗口上跳出浏览器的抱怨,日志里像浏览器坏了,工具结果里什么都没有)。
|
||||||
|
// 没有输出时也如实点明,那本身是线索(参数被拒之类通常会带话)。
|
||||||
|
func withChromeOutput(err error, preview string) error {
|
||||||
|
if preview == "" {
|
||||||
|
return fmt.Errorf("%w;浏览器没有输出任何内容", err)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%w;浏览器输出:%s", err, preview)
|
||||||
|
}
|
||||||
|
|
||||||
|
// endpointBrokenHint 是「端口在听、却握不上手」的**可操作**处置建议。它只给**不动别人进程**的
|
||||||
|
// 出路(红线:占着这个 profile 的浏览器可能是主人在用的那个,我们不杀、也不发任何信号去抢)。
|
||||||
|
func endpointBrokenHint(profileDir string) string {
|
||||||
|
return fmt.Sprintf("处置:先确认是谁占着这个端口(本代理不会、也不该杀任何进程);"+
|
||||||
|
"确认它不是本 profile 的浏览器调试端点后,删掉 %s 再重试,或把 profileDir 指到另一个目录。",
|
||||||
|
filepath.Join(profileDir, devToolsPortFile))
|
||||||
|
}
|
||||||
|
|
||||||
|
// profileBusyHint 是「新进程立刻退出」的处置建议:Chromium 把命令委托给了同一个 profile 上已在
|
||||||
|
// 跑的实例,然后自己退出 —— 给出路,但不碰任何不属于我们的进程。
|
||||||
|
func profileBusyHint(profileDir string) string {
|
||||||
|
return fmt.Sprintf("多半是该 profile(%s)已被另一个浏览器实例占用。"+
|
||||||
|
"处置:若那个实例是上一次会话留下的**我们的**浏览器、且调试端点还在,它在启动前就已被复用;"+
|
||||||
|
"走到这里说明它没有可用的调试端点(例如是被人手动用这个 profile 起的)。"+
|
||||||
|
"请先关掉那个浏览器窗口再重试,或把 profileDir 指到另一个目录 —— 本代理绝不杀别人的浏览器。",
|
||||||
|
profileDir)
|
||||||
|
}
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
package browser
|
||||||
|
|
||||||
|
// 本文件盯住「起进程之前的三条判定」(实现见 adopt.go):
|
||||||
|
//
|
||||||
|
// ① 端点活着 ⇒ **复用**,不起第二个进程;
|
||||||
|
// ② 端口记录在、端口没人听 ⇒ 清掉陈记录后照常启动;
|
||||||
|
// ③ 端口在听却握不上手 ⇒ 如实报错(带处置建议),不起进程;
|
||||||
|
// ④ 被占用且拿不到端点 ⇒ 情况②之外的「真被占用」:如实报 + 带上 Chrome 自己的原话。
|
||||||
|
//
|
||||||
|
// 判据①必须有**能证伪**的观察手段:怎么证明「没起第二个进程」?答案是**诱饵** ——
|
||||||
|
// 这些用例给 localChrome 配的 exe 是「一被拉起就大声喊、然后退出」的假浏览器
|
||||||
|
// (fakebrowser_test.go 的重跑自己范式)。真走了启动路径,喊声会进日志、Dial 也会失败;
|
||||||
|
// 用例因此是绿的 ⇒ 启动路径没被走到。(变异自证:把复用那一支去掉 ⇒ 判据①立刻变红。)
|
||||||
|
//
|
||||||
|
// 现场依据(主人实测复现):同一个 profile 上再起一次 Chrome 时,新进程 exit 0、不写新的
|
||||||
|
// DevToolsActivePort,stderr 逐字只有一句「正在现有的浏览器会话中打开。」—— Chromium 把
|
||||||
|
// 命令委托给了已在跑的实例。不做复用,这一档就一直报启动失败,直到那个进程退出。
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeBrowserLure 是诱饵:假浏览器一旦被拉起就喊这句,用例据此判定「起了第二个进程」。
|
||||||
|
const fakeBrowserLure = "假浏览器被拉起了 —— 这一档不该有第二个进程"
|
||||||
|
|
||||||
|
// chromeSaysSessionOpen 是真 Chrome 把命令委托给现存实例时写出的那句 stderr 原话。
|
||||||
|
const chromeSaysSessionOpen = "正在现有的浏览器会话中打开。"
|
||||||
|
|
||||||
|
// deadPort 返回一个「刚刚还活着、现在没人听」的回环端口号:用来造**陈**的端口记录。
|
||||||
|
func deadPort(t *testing.T) int {
|
||||||
|
t.Helper()
|
||||||
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("取空闲端口: %v", err)
|
||||||
|
}
|
||||||
|
port := ln.Addr().(*net.TCPAddr).Port
|
||||||
|
if err := ln.Close(); err != nil {
|
||||||
|
t.Fatalf("释放端口: %v", err)
|
||||||
|
}
|
||||||
|
return port
|
||||||
|
}
|
||||||
|
|
||||||
|
// portOfEndpoint 取 `http://127.0.0.1:PORT` 里的端口号(写端口记录用)。
|
||||||
|
func portOfEndpoint(t *testing.T, endpoint string) int {
|
||||||
|
t.Helper()
|
||||||
|
port, err := strconv.Atoi(strings.TrimPrefix(endpoint, "http://127.0.0.1:"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("从 %q 取端口: %v", endpoint, err)
|
||||||
|
}
|
||||||
|
return port
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeDevToolsPortFile 写一条端口记录(真浏览器的格式:首行端口、次行 browser_guid)。
|
||||||
|
func writeDevToolsPortFile(t *testing.T, dir string, port int) {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(dir, devToolsPortFile)
|
||||||
|
if err := os.WriteFile(path, []byte(strconv.Itoa(port)+"\nGUID\n"), 0o600); err != nil {
|
||||||
|
t.Fatalf("写端口记录: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// portFileExists 报告端口记录还在不在(不清陈记录 / 保留现场两类断言都用它)。
|
||||||
|
func portFileExists(dir string) bool {
|
||||||
|
_, err := os.Stat(filepath.Join(dir, devToolsPortFile))
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDial_ReusesLiveEndpointAndDoesNotSpawnSecondProcess 是判据①:profile 里记的调试端点
|
||||||
|
// 在听、`/json/version` 也答得上来 ⇒ **连它**,不出第二个进程。
|
||||||
|
//
|
||||||
|
// 复用的必要性不只是省一次启动:同 profile 上起第二个实例时 Chromium 会把命令委托出去后自己
|
||||||
|
// 退出,新进程永远不写端口记录 ⇒ 不复用就等于这一档不可用(详见 adopt.go 的文件头)。
|
||||||
|
func TestDial_ReusesLiveEndpointAndDoesNotSpawnSecondProcess(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
cdp := newFakeCDP(t, func(string, map[string]any) (any, *cdpError) { return map[string]any{}, nil })
|
||||||
|
log, buf := newCapturedLog(slog.LevelDebug)
|
||||||
|
l := newFakeChrome(t, fakeBrowserScript{Stderr: []string{fakeBrowserLure}, ExitCode: 9}, 5*time.Second, log)
|
||||||
|
writeDevToolsPortFile(t, l.profileDir, portOfEndpoint(t, cdp.base))
|
||||||
|
|
||||||
|
endpoint, stop, err := l.Dial(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("活着且握手成功的端点应当被复用,实得错误: %v", err)
|
||||||
|
}
|
||||||
|
defer stop()
|
||||||
|
if endpoint != cdp.base {
|
||||||
|
t.Errorf("endpoint = %q, want %q(必须是复用来的那个端点)", endpoint, cdp.base)
|
||||||
|
}
|
||||||
|
logs := buf.String()
|
||||||
|
t.Logf("实测日志:\n%s", logs)
|
||||||
|
if containsInLog(logs, fakeBrowserLure) {
|
||||||
|
t.Errorf("起了第二个进程(诱饵被拉起)—— 复用这一支没生效:\n%s", logs)
|
||||||
|
}
|
||||||
|
if !strings.Contains(logs, "browser debug endpoint reused") {
|
||||||
|
t.Errorf("复用必须留一条 INFO(说明复用了已存在的调试端点):\n%s", logs)
|
||||||
|
}
|
||||||
|
if !strings.Contains(logs, "source="+logSource) {
|
||||||
|
t.Errorf("这条 INFO 必须带来源(AGENTS §10 禁无来源裸日志):\n%s", logs)
|
||||||
|
}
|
||||||
|
stop() // 复用来的实例不归我们杀:停止函数必须是空操作(能调用即通过)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDial_ClearsStalePortFileThenLaunches 是判据②:端口记录在、但端口已经没人听 ⇒ 那是
|
||||||
|
// **陈记录**,清掉后照常启动。两个子用例各钉一半:
|
||||||
|
//
|
||||||
|
// (1) 清掉之后,本次就绪判定认**新进程写的**那个端口;
|
||||||
|
// (2) 清掉是**真清**:新进程不写端口时,Dial 必须报「等端口超时」—— 而不是把陈端口当成
|
||||||
|
// 本次结果稀里糊涂地成功(那正是「不清陈记录」会造成的伪装)。
|
||||||
|
func TestDial_ClearsStalePortFileThenLaunches(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
t.Run("以新进程写的端口为准", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
log, _ := newCapturedLog(slog.LevelDebug)
|
||||||
|
l := newFakeChrome(t, fakeBrowserScript{WritePort: 55543, SleepMS: 3000}, 5*time.Second, log)
|
||||||
|
writeDevToolsPortFile(t, l.profileDir, deadPort(t))
|
||||||
|
|
||||||
|
endpoint, stop, err := l.Dial(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("陈记录不该挡住正常启动: %v", err)
|
||||||
|
}
|
||||||
|
defer stop()
|
||||||
|
if want := "http://127.0.0.1:55543"; endpoint != want {
|
||||||
|
t.Errorf("endpoint = %q, want %q(陈端口被当成本次结果了?)", endpoint, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("陈端口不会被当成本次结果", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
log, buf := newCapturedLog(slog.LevelDebug)
|
||||||
|
l := newFakeChrome(t, fakeBrowserScript{
|
||||||
|
Stderr: []string{"起来了,但从不写端口记录"}, SleepMS: 3000,
|
||||||
|
}, 300*time.Millisecond, log)
|
||||||
|
stale := deadPort(t)
|
||||||
|
writeDevToolsPortFile(t, l.profileDir, stale)
|
||||||
|
|
||||||
|
endpoint, _, err := l.Dial(context.Background())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("陈记录被清掉、新进程又不写端口 ⇒ 必须报错;实得 endpoint=%q", endpoint)
|
||||||
|
}
|
||||||
|
t.Logf("实测 error=%v\n实测日志=%s", err, buf.String())
|
||||||
|
if !errors.Is(err, ErrLaunch) {
|
||||||
|
t.Fatalf("仍须包住 ErrLaunch: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), launchMarkerPortTimeout) {
|
||||||
|
t.Errorf("应当读成「等 DevToolsActivePort 超时」: %v", err)
|
||||||
|
}
|
||||||
|
if endpoint == fmt.Sprintf("http://127.0.0.1:%d", stale) {
|
||||||
|
t.Errorf("把陈端口当成本次结果了(endpoint=%q)", endpoint)
|
||||||
|
}
|
||||||
|
if portFileExists(l.profileDir) {
|
||||||
|
t.Error("陈的端口记录应当已被清掉(否则下次还会读到它)")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDial_ReportsLiveButUnusableEndpoint 是判据③:端口在听、但 `/json/version` 握不上手 ⇒
|
||||||
|
// **如实报错**,而且**不许静默忽略后另起一个进程**(起出来的那个只会委托出去再退出,把原因
|
||||||
|
// 糊成一句「启动失败」)。三种「在听但不是 CDP」的形态各钉一格。
|
||||||
|
func TestDial_ReportsLiveButUnusableEndpoint(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
handler http.HandlerFunc
|
||||||
|
}{
|
||||||
|
{"响应不是 JSON", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
_, _ = w.Write([]byte("<html>我不是 CDP</html>"))
|
||||||
|
}},
|
||||||
|
{"JSON 但没有 Browser 字段", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(`{"hello":"world"}`))
|
||||||
|
}},
|
||||||
|
{"HTTP 503", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
srv := httptest.NewServer(tc.handler)
|
||||||
|
defer srv.Close()
|
||||||
|
log, buf := newCapturedLog(slog.LevelDebug)
|
||||||
|
l := newFakeChrome(t, fakeBrowserScript{Stderr: []string{fakeBrowserLure}, ExitCode: 9}, 3*time.Second, log)
|
||||||
|
writeDevToolsPortFile(t, l.profileDir, portOfEndpoint(t, srv.URL))
|
||||||
|
|
||||||
|
_, stop, err := l.Dial(context.Background())
|
||||||
|
if stop != nil {
|
||||||
|
t.Error("失败路径不该交出停止函数")
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("端口在听却握不上手时必须如实报错,不许静默忽略")
|
||||||
|
}
|
||||||
|
t.Logf("实测 error=%v\n实测日志=%s", err, buf.String())
|
||||||
|
if !errors.Is(err, ErrLaunch) {
|
||||||
|
t.Fatalf("仍须包住 ErrLaunch: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), launchMarkerEndpointBroken) {
|
||||||
|
t.Errorf("错误里应点明这一类(端口在听但不是 CDP 端点): %v", err)
|
||||||
|
}
|
||||||
|
for _, want := range []string{"处置", devToolsPortFile, l.profileDir} {
|
||||||
|
if !containsInLog(err.Error(), want) {
|
||||||
|
t.Errorf("可操作的处置建议里缺少 %q: %v", want, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if containsInLog(buf.String(), fakeBrowserLure) {
|
||||||
|
t.Errorf("不许静默忽略后另起一个进程(诱饵被拉起):\n%s", buf.String())
|
||||||
|
}
|
||||||
|
if !portFileExists(l.profileDir) {
|
||||||
|
t.Error("握不上手时不该动端口记录:它是现场证据,也是复用判定的唯一依据")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDial_ReportsOccupiedProfileWithChromeOutput 是判据④,也是本片要修的那个「必然等不到
|
||||||
|
// 端口」的场景在**我们没法复用**时的出口:新进程 exit 0(真机就是这样)、不写端口记录,
|
||||||
|
// Chrome 自己只在 stderr 说一句「正在现有的浏览器会话中打开。」。
|
||||||
|
//
|
||||||
|
// 要求:立刻报(不把预算干等完)、说清是哪一类(launchMarkerProcessExited)、给可操作建议、
|
||||||
|
// 并且把 **Chrome 自己的原话** 带进**返回给调用方**的错误里(用户侧的工具结果来自这条错误,
|
||||||
|
// 只留在日志里等于用户看不到)。
|
||||||
|
func TestDial_ReportsOccupiedProfileWithChromeOutput(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
log, buf := newCapturedLog(slog.LevelDebug)
|
||||||
|
l := newFakeChrome(t, fakeBrowserScript{Stderr: []string{chromeSaysSessionOpen}, ExitCode: 0}, 5*time.Second, log)
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
_, stop, err := l.Dial(context.Background())
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
if stop != nil {
|
||||||
|
t.Error("失败路径不该交出停止函数")
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("进程 exit 0 却不写端口记录 ⇒ 必须报错(真机就是这个形态)")
|
||||||
|
}
|
||||||
|
logs := buf.String()
|
||||||
|
t.Logf("实测 error=%v\n实测耗时=%v\n实测日志=%s", err, elapsed, logs)
|
||||||
|
if elapsed > 2*time.Second {
|
||||||
|
t.Errorf("应当立刻报,不把配置的 timeout 白等完:等了 %v", elapsed)
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrLaunch) {
|
||||||
|
t.Fatalf("仍须包住 ErrLaunch: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), launchMarkerProcessExited) {
|
||||||
|
t.Errorf("错误里应点明「进程立刻退出」这一类: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), chromeSaysSessionOpen) {
|
||||||
|
t.Errorf("错误里必须带上 Chrome 自己的原话(用户侧只看得到这条错误): %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "浏览器输出:") {
|
||||||
|
t.Errorf("原话应当以「浏览器输出:」引出(对应 withChromeOutput): %v", err)
|
||||||
|
}
|
||||||
|
for _, want := range []string{"处置", "关掉那个浏览器窗口", "绝不杀别人的浏览器", l.profileDir} {
|
||||||
|
if !containsInLog(err.Error(), want) {
|
||||||
|
t.Errorf("可操作的处置建议里缺少 %q: %v", want, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, want := range []string{
|
||||||
|
"--user-data-dir=" + l.profileDir, // 现场可复现:本次 argv
|
||||||
|
"chrome_output_bytes=", // 浏览器说了多少
|
||||||
|
chromeSaysSessionOpen, // 日志里也要有原话
|
||||||
|
} {
|
||||||
|
if !containsInLog(logs, want) {
|
||||||
|
t.Errorf("失败日志里缺少 %q:\n%s", want, logs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,16 +32,19 @@ const (
|
|||||||
defaultLang = "zh-CN"
|
defaultLang = "zh-CN"
|
||||||
)
|
)
|
||||||
|
|
||||||
// adoptProbeTimeout 是「要不要认领已在跑的实例」这一次探测的超时(也是远端端点探活
|
// adoptProbeTimeout 是「要不要复用已在跑的实例」这一次探测的超时(也是远端端点探活
|
||||||
// 用的那一档):它只问「端口有没有在听」,给得短。
|
// 用的那一档):它先问「端口有没有在听」,再问 `/json/version` 答不答话,两问都给得短 ——
|
||||||
|
// 这是启动前的判定,不该拖住启动路径。
|
||||||
const adoptProbeTimeout = 2 * time.Second
|
const adoptProbeTimeout = 2 * time.Second
|
||||||
|
|
||||||
// 启动失败的**分类标记**:三种情形必须在错误消息里一眼可分(design/28 §7 判据 4
|
// 启动失败的**分类标记**:几种情形必须在错误消息里一眼可分(design/28 §7 判据 4
|
||||||
// 「如实标注」/ §6 坑 5「失败要如实报」)——
|
// 「如实标注」/ §6 坑 5「失败要如实报」)——
|
||||||
//
|
//
|
||||||
// ① 浏览器进程自己退了(参数被拒 / 委托给已跑实例 / profile 锁被占);
|
// ① 浏览器进程自己退了(参数被拒 / 委托给已跑实例 / profile 锁被占);
|
||||||
// ② 我们自己的等待窗口用完(DevToolsActivePort 没出现);
|
// ② 我们自己的等待窗口用完(DevToolsActivePort 没出现);
|
||||||
// ③ **调用方的预算先没了**:浏览器没坏,只是没时间了。
|
// ③ **调用方的预算先没了**:浏览器没坏,只是没时间了;
|
||||||
|
// ④ 上一轮记的调试端口**在听、但不是可用的 CDP 端点**(握手失败)—— 它既不是「进程退出」
|
||||||
|
// 也不是「端口没出现」,必须单独说,因为它有**自己的**处置方式(见 endpointBrokenHint)。
|
||||||
//
|
//
|
||||||
// 哨兵仍然只有 ErrLaunch 一个(`errors.Is` 口径不变),这些标记是消息里可机读的那一段。
|
// 哨兵仍然只有 ErrLaunch 一个(`errors.Is` 口径不变),这些标记是消息里可机读的那一段。
|
||||||
const (
|
const (
|
||||||
@@ -51,6 +54,10 @@ const (
|
|||||||
launchMarkerPortTimeout = "等 DevToolsActivePort 超时"
|
launchMarkerPortTimeout = "等 DevToolsActivePort 超时"
|
||||||
// launchMarkerBudget 标记 ③。
|
// launchMarkerBudget 标记 ③。
|
||||||
launchMarkerBudget = "调用方预算耗尽"
|
launchMarkerBudget = "调用方预算耗尽"
|
||||||
|
// launchMarkerEndpointBroken 标记 ④:端口在听却握不上手。它与「陈文件」(端口没人听)
|
||||||
|
// 是**两回事**:陈文件可以安全清掉后照常启动,这里只能如实报 —— 静默忽略它去另起一个
|
||||||
|
// 进程,换来的只是「委托给已跑实例后立刻退出」,把原因糊掉。
|
||||||
|
launchMarkerEndpointBroken = "调试端口在听但不是可用的 CDP 端点"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -116,24 +123,30 @@ func (l *localChrome) Local() bool { return true }
|
|||||||
|
|
||||||
// Dial 拉起浏览器并等调试端口就绪。任何一步失败都返回带分类原因的 ErrLaunch。
|
// Dial 拉起浏览器并等调试端口就绪。任何一步失败都返回带分类原因的 ErrLaunch。
|
||||||
//
|
//
|
||||||
// 三条**先于启动**的判定(前两条是为了让「上一次会话留下的浏览器」不至于把功能卡死):
|
// 四条**先于启动**的判定(前三条都是为了让「上一次会话留下的浏览器」不至于把功能卡死):
|
||||||
//
|
//
|
||||||
// 1. **预算已耗尽就不起进程**:ctx 已经结束(这一轮的工具预算用完 / 会话被取消)时
|
// 1. **预算已耗尽就不起进程**:ctx 已经结束(这一轮的工具预算用完 / 会话被取消)时
|
||||||
// 立刻返回「预算耗尽」分类,**不 spawn** —— 起了也只是生一个会被马上杀掉的窗口,
|
// 立刻返回「预算耗尽」分类,**不 spawn** —— 起了也只是生一个会被马上杀掉的窗口,
|
||||||
// 并把「没时间了」伪装成「浏览器起不来」(真机踩过:窗口上跳出浏览器的抱怨,日志
|
// 并把「没时间了」伪装成「浏览器起不来」(真机踩过:窗口上跳出浏览器的抱怨,日志
|
||||||
// 里却像浏览器坏了)。判定做在**启动之前**,因为它要省掉的正是那次启动。
|
// 里却像浏览器坏了)。判定做在**启动之前**,因为它要省掉的正是那次启动。
|
||||||
// 2. **认领已在跑的实例**:profile 目录的 DevToolsActivePort 还活着(端口在听)⇒ 直接
|
// 2. **能复用就复用**(`probeExistingEndpoint`):profile 目录的 DevToolsActivePort 还在、
|
||||||
// 出站连接它,**不再起第二个**。这不是优化,是**正确性**:同一个 user-data-dir 上起
|
// 端口**确实在听**、`/json/version` 也**真有合法响应** ⇒ 直接出站连它,**不再起第二个**。
|
||||||
// 第二个实例时 Chromium 会「委托给已在跑的那个」然后自己退出,于是新进程永远不写
|
// 这不是优化,是**正确性**:同一个 user-data-dir 上起第二个实例时 Chromium 会把手上的
|
||||||
// DevToolsActivePort ⇒ 功能会一直报启动超时,直到人工杀掉那个进程。认领把这一步
|
// 命令**委托给已在跑的那个**然后自己退出(真机实测:新进程 exit 0、不写新的
|
||||||
// 变成「复用」,也更贴合 design/28 §2「profile 长期保留、任务间复用」。
|
// DevToolsActivePort,stderr 只有一句「正在现有的浏览器会话中打开。」),于是新进程永远
|
||||||
// 3. **端口文件先清掉**:只在真的要走启动路径时才清,否则会把旧端口当成本次结果。
|
// 不写端口 ⇒ 不复用的话,这一档会一直报启动失败,直到人工杀掉那个进程。复用还正好贴合
|
||||||
|
// design/28 §2「profile 长期保留、任务间复用」。
|
||||||
|
// 3. **端口在听、却握不上手 ⇒ 如实报错**(`launchMarkerEndpointBroken`):绝不「忽略掉它、
|
||||||
|
// 自己起一个」—— 占着这个端口的多半就是同一个 profile 的浏览器,起进程只会让新进程委托
|
||||||
|
// 出去后立刻退出,把一句本就查不明白的失败再糊一层。
|
||||||
|
// 4. **陈的端口记录先清掉**(`clearStalePortFile`):只在真的要走启动路径时才清;不清的话,
|
||||||
|
// 上一轮的端口会被当成本次结果(理由见那个方法的注释)。
|
||||||
//
|
//
|
||||||
// 子进程的 stdout/stderr **接进缓冲**(`chromeOutput`):启动失败时把浏览器自己的话与
|
// 子进程的 stdout/stderr **接进缓冲**(`chromeOutput`):启动失败时把浏览器自己的话与本次
|
||||||
// 本次 argv 一起报进日志 —— 这是「参数被拒 / 端口没出来 / 预算没了」三者可分的前提。
|
// argv 一起报进**日志**,并把浏览器原话附进**返回的错误**(`withChromeOutput`)—— 用户侧看到的
|
||||||
// 成功时只报字节数(Debug),不把 Chrome 的常规输出刷进日志。
|
// 工具结果就是从这条错误来的。成功时只报字节数(Debug),不把 Chrome 的常规输出刷进日志。
|
||||||
//
|
//
|
||||||
// 停止函数只对**本次启动**的进程有效(认领来的实例不归我们杀):进程退出后 Chrome 的
|
// 停止函数只对**本次启动**的进程有效(复用来的实例不归我们杀):进程退出后 Chrome 的
|
||||||
// 渲染进程随之退出,因此进程级 Kill 足够。
|
// 渲染进程随之退出,因此进程级 Kill 足够。
|
||||||
func (l *localChrome) Dial(ctx context.Context) (string, func(), error) {
|
func (l *localChrome) Dial(ctx context.Context) (string, func(), error) {
|
||||||
if strings.TrimSpace(l.exe) == "" {
|
if strings.TrimSpace(l.exe) == "" {
|
||||||
@@ -142,14 +155,26 @@ func (l *localChrome) Dial(ctx context.Context) (string, func(), error) {
|
|||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return "", nil, budgetLaunchErr(ctx)
|
return "", nil, budgetLaunchErr(ctx)
|
||||||
}
|
}
|
||||||
if endpoint, ok := l.adoptExisting(ctx); ok {
|
endpoint, err := l.probeExistingEndpoint(ctx)
|
||||||
l.log.Info("browser session adopted", "endpoint", endpoint, "profile", l.profileDir)
|
if err != nil {
|
||||||
|
// 复用不了、又不许起第二个:如实报(错误里已带处置建议),一条 WARN 留现场。
|
||||||
|
l.log.Warn("browser launch blocked by unusable debug endpoint",
|
||||||
|
"profile", l.profileDir, "error", err)
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
if endpoint != "" {
|
||||||
|
l.log.Info("browser debug endpoint reused",
|
||||||
|
"endpoint", endpoint,
|
||||||
|
"profile", l.profileDir,
|
||||||
|
"detail", "复用已存在的调试端点(同 profile 的浏览器仍在跑,不再起第二个)")
|
||||||
return endpoint, func() {}, nil
|
return endpoint, func() {}, nil
|
||||||
}
|
}
|
||||||
if err := os.MkdirAll(l.profileDir, 0o700); err != nil {
|
if err := os.MkdirAll(l.profileDir, 0o700); err != nil {
|
||||||
return "", nil, fmt.Errorf("%w: 建 profile 目录 %s: %v", ErrLaunch, l.profileDir, err)
|
return "", nil, fmt.Errorf("%w: 建 profile 目录 %s: %v", ErrLaunch, l.profileDir, err)
|
||||||
}
|
}
|
||||||
_ = os.Remove(filepath.Join(l.profileDir, devToolsPortFile))
|
if err := l.clearStalePortFile(); err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
|
||||||
args := l.launchArgs()
|
args := l.launchArgs()
|
||||||
cmd := exec.Command(l.exe, args...)
|
cmd := exec.Command(l.exe, args...)
|
||||||
@@ -176,7 +201,7 @@ func (l *localChrome) Dial(ctx context.Context) (string, func(), error) {
|
|||||||
"chrome_output", out.preview(),
|
"chrome_output", out.preview(),
|
||||||
"chrome_output_bytes", out.totalBytes(),
|
"chrome_output_bytes", out.totalBytes(),
|
||||||
"error", err)
|
"error", err)
|
||||||
return "", nil, err
|
return "", nil, withChromeOutput(err, out.preview())
|
||||||
}
|
}
|
||||||
l.log.Debug("browser launched",
|
l.log.Debug("browser launched",
|
||||||
"exe", l.exe, "port", port, "profile", l.profileDir, "chrome_output_bytes", out.totalBytes())
|
"exe", l.exe, "port", port, "profile", l.profileDir, "chrome_output_bytes", out.totalBytes())
|
||||||
@@ -205,24 +230,8 @@ func budgetLaunchErr(ctx context.Context) error {
|
|||||||
return fmt.Errorf("%w: %s:%s,浏览器未启动完成", ErrLaunch, launchMarkerBudget, detail)
|
return fmt.Errorf("%w: %s:%s,浏览器未启动完成", ErrLaunch, launchMarkerBudget, detail)
|
||||||
}
|
}
|
||||||
|
|
||||||
// adoptExisting 报告 profile 目录里记录的调试端点是否**仍然活着**;活着就返回它。
|
// 启动前的三条判定与它们的处置文案在 adopt.go:复用活端点(probeExistingEndpoint)、
|
||||||
// 探测超时给得很短:这是一个「要不要复用」的判断,不该拖住启动路径。
|
// 清掉陈的端口记录(clearStalePortFile)、端点不可用时的如实上报(withChromeOutput 与两条 hint)。
|
||||||
func (l *localChrome) adoptExisting(ctx context.Context) (string, bool) {
|
|
||||||
port, ok := readDevToolsPort(filepath.Join(l.profileDir, devToolsPortFile))
|
|
||||||
if !ok {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
endpoint := fmt.Sprintf("http://127.0.0.1:%d", port)
|
|
||||||
probeCtx, cancel := context.WithTimeout(ctx, adoptProbeTimeout)
|
|
||||||
defer cancel()
|
|
||||||
var version struct {
|
|
||||||
Browser string `json:"Browser"`
|
|
||||||
}
|
|
||||||
if err := httpGetJSON(probeCtx, endpoint+"/json/version", &version); err != nil {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
return endpoint, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// launchArgs 组装启动参数(**顺序稳定、可断言**:单元测试钉住白/黑名单)。
|
// launchArgs 组装启动参数(**顺序稳定、可断言**:单元测试钉住白/黑名单)。
|
||||||
func (l *localChrome) launchArgs() []string {
|
func (l *localChrome) launchArgs() []string {
|
||||||
@@ -254,13 +263,18 @@ func (l *localChrome) launchArgs() []string {
|
|||||||
// 而且三者互不冒充(design/28 §7 判据 4「如实标注」):
|
// 而且三者互不冒充(design/28 §7 判据 4「如实标注」):
|
||||||
//
|
//
|
||||||
// ① 浏览器进程自己退了(多半是 profile 锁被占 / 浏览器已在运行)——错误里带
|
// ① 浏览器进程自己退了(多半是 profile 锁被占 / 浏览器已在运行)——错误里带
|
||||||
// launchMarkerProcessExited;
|
// launchMarkerProcessExited **与可操作的处置建议**(profileBusyHint);子进程说的原话
|
||||||
|
// 由 Dial 追加进这条错误(withChromeOutput),用户侧因此看得见 Chromium 那句
|
||||||
|
// 「正在现有的浏览器会话中打开。」;
|
||||||
// ② 我们自己的等待窗口用完 —— 带 launchMarkerPortTimeout;
|
// ② 我们自己的等待窗口用完 —— 带 launchMarkerPortTimeout;
|
||||||
// ③ 调用方的预算先没了(ctx 结束)—— 带 launchMarkerBudget,**不**说浏览器坏。
|
// ③ 调用方的预算先没了(ctx 结束)—— 带 launchMarkerBudget,**不**说浏览器坏。
|
||||||
//
|
//
|
||||||
// ② 与 ③ 的分辨靠两者的截止时间:ctx 的截止时间**明显早于**我们这一次等待的截止时间
|
// ② 与 ③ 的分辨靠两者的截止时间:ctx 的截止时间**明显早于**我们这一次等待的截止时间
|
||||||
// 时,结束它的是调用方;几乎同时到点时按 ② 报(那是这一次等待真的用满了配置的 timeout,
|
// 时,结束它的是调用方;几乎同时到点时按 ② 报(那是这一次等待真的用满了配置的 timeout,
|
||||||
// 也是真机常态:外层 openSession 的预算与本次等待同源同值)。
|
// 也是真机常态:外层 openSession 的预算与本次等待同源同值)。
|
||||||
|
//
|
||||||
|
// 还有一条**不走这里**的出口:启动前探到「端口在听却握不上手」时,Dial 会在起进程之前就
|
||||||
|
// 报 launchMarkerEndpointBroken(见 adopt.go)—— 那种情形根本不该起这个进程。
|
||||||
func (l *localChrome) waitDevToolsPort(ctx context.Context, exited <-chan struct{}) (int, error) {
|
func (l *localChrome) waitDevToolsPort(ctx context.Context, exited <-chan struct{}) (int, error) {
|
||||||
path := filepath.Join(l.profileDir, devToolsPortFile)
|
path := filepath.Join(l.profileDir, devToolsPortFile)
|
||||||
launchDeadline := time.Now().Add(l.timeout)
|
launchDeadline := time.Now().Add(l.timeout)
|
||||||
@@ -275,9 +289,10 @@ func (l *localChrome) waitDevToolsPort(ctx context.Context, exited <-chan struct
|
|||||||
select {
|
select {
|
||||||
case <-exited:
|
case <-exited:
|
||||||
// 浏览器进程自己退了,端口不会再出现 —— **立刻**报,不把超时白等完。
|
// 浏览器进程自己退了,端口不会再出现 —— **立刻**报,不把超时白等完。
|
||||||
// 最常见的原因是同一 profile 已被另一个实例占用(Chromium 的 profile 锁)
|
// 最常见的原因是同一 profile 已被另一个实例占用:Chromium 把命令**委托给已在跑的
|
||||||
// 或浏览器已被别处启动:委托出去的新进程不写 DevToolsActivePort。
|
// 那个**之后自己退出(真机实测 stderr 原话「正在现有的浏览器会话中打开。」)、新
|
||||||
return 0, fmt.Errorf("%w: %s(多半是该 profile 已被另一个实例占用)", ErrLaunch, launchMarkerProcessExited)
|
// 进程不写 DevToolsActivePort。这里给可操作建议,但不碰别人的进程。
|
||||||
|
return 0, fmt.Errorf("%w: %s。%s", ErrLaunch, launchMarkerProcessExited, profileBusyHint(l.profileDir))
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
if dl, ok := ctx.Deadline(); !ok || dl.Add(launchDeadlineSlack).Before(launchDeadline) {
|
if dl, ok := ctx.Deadline(); !ok || dl.Add(launchDeadlineSlack).Before(launchDeadline) {
|
||||||
return 0, budgetLaunchErr(ctx)
|
return 0, budgetLaunchErr(ctx)
|
||||||
|
|||||||
@@ -152,7 +152,8 @@ func TestLocalChromeAdoptsLiveEndpoint(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestLocalChromeDoesNotAdoptDeadEndpoint 钉住反面:端口文件在、但没人听 ⇒ **不认领**
|
// TestLocalChromeDoesNotAdoptDeadEndpoint 钉住反面:端口文件在、但没人听 ⇒ **不认领**
|
||||||
// (否则会连到一个死端口,比启动失败更难排查)。
|
// (否则会连到一个死端口,比启动失败更难排查)。它同时钉住「没人听」的**分类**:
|
||||||
|
// 探测要给出 ("", nil) 而不是错误 —— 这是「陈文件,可以清掉后照常启动」的那一条出路。
|
||||||
func TestLocalChromeDoesNotAdoptDeadEndpoint(t *testing.T) {
|
func TestLocalChromeDoesNotAdoptDeadEndpoint(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
@@ -160,9 +161,13 @@ func TestLocalChromeDoesNotAdoptDeadEndpoint(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
l := &localChrome{exe: "x", profileDir: dir, timeout: time.Second, log: discardLog()}
|
l := &localChrome{exe: "x", profileDir: dir, timeout: time.Second, log: discardLog()}
|
||||||
if endpoint, ok := l.adoptExisting(context.Background()); ok {
|
endpoint, err := l.probeExistingEndpoint(context.Background())
|
||||||
|
if endpoint != "" {
|
||||||
t.Fatalf("死端口不该被认领(endpoint=%q)", endpoint)
|
t.Fatalf("死端口不该被认领(endpoint=%q)", endpoint)
|
||||||
}
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("死端口是「陈文件」而不是「坏端点」,探测不该报错: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestWaitDevToolsPort_ReportsEarlyExit 钉住「进程立刻退出」要**立刻**如实报,并把
|
// TestWaitDevToolsPort_ReportsEarlyExit 钉住「进程立刻退出」要**立刻**如实报,并把
|
||||||
|
|||||||
Reference in new issue
Block a user