修复元素拾取 Chrome 启动失败(exit 0): 残留实例占用专用 profile

根因: 上次运行遗留的 Chrome 仍占用 rpa-studio-external-* profile 时,
再次启动的 Chrome 会把请求转交旧实例并立即退出(代码 0), 报
「Chrome 启动失败(进程已退出,代码 0)」。

- external: 子进程立即退出时清理占用该 profile 的残留进程并重试一次
  (win32 按命令行中的 user-data-dir 过滤杀进程, 其他平台 pkill -f)
- 启动参数加 --hide-crash-restore-bubble, 避免固定 profile 被强杀后
  「恢复页面?」气泡遮挡页面拾取
- 验证: 故意拉起无调试端口的同 profile Chrome 后跑浏览器 e2e, 自动
  清理重试生效, 演示流程 succeeded
This commit is contained in:
yu 2026-11-04 17:45:29 +08:00
parent 61a7f60a0e
commit ea3ff0b891

View File

@ -1,10 +1,13 @@
import { execFile, spawn, type ChildProcess } from "node:child_process";
import { promisify } from "node:util";
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import { RuntimeError } from "../engine/types";
import { ExternalTransport } from "./transport";
const execAsync = promisify(execFile);
/**
* 外部浏览器(Edge / Chrome)进程管理与 CDP 连接。
*
@ -209,6 +212,27 @@ export async function connectRemoteBrowser(options: ExternalRemoteOptions): Prom
};
}
/** 清理占用专用 profile 的残留浏览器进程。
* 同一 user-data-dir 已有实例时,再次启动的 Chrome 会把请求转交旧实例并立即退出(代码 0),
* 导致「启动失败(进程已退出,代码 0)」——这些实例都是本模块之前拉起的,清理是安全的。 */
async function killStaleBrowserProcesses(browserType: ExternalBrowserType, userDataDir: string): Promise<void> {
try {
if (process.platform === "win32") {
const names = browserType === "edge" ? "msedge.exe" : "chrome.exe";
const marker = userDataDir.replace(/'/g, "''");
const script =
`Get-CimInstance Win32_Process -Filter "Name='${names}'" | ` +
`Where-Object { $_.CommandLine -like '*${marker}*' } | ` +
"ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }";
await execAsync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { timeout: 8000, windowsHide: true });
} else {
await execAsync("pkill", ["-f", userDataDir], { timeout: 5000 });
}
} catch {
// 清理失败不阻塞启动:可能本来就没有残留,或权限受限
}
}
/** 拉起本机 Edge/Chrome(带独立 profile + 调试端口),并接管其页面 */
export async function launchExternalBrowser(options: ExternalLaunchOptions): Promise<ExternalConnection> {
const browserType = options.browserType === "edge" ? "edge" : "chrome";
@ -233,11 +257,14 @@ export async function launchExternalBrowser(options: ExternalLaunchOptions): Pro
`--user-data-dir=${userDataDir}`,
"--no-first-run",
"--no-default-browser-check",
// 固定 profile 曾被强杀过时,Chrome 会弹「恢复页面?」气泡挡住页面拾取
"--hide-crash-restore-bubble",
startUrl,
...extraArgs
];
const child = spawn(browserPath, args, { detached: false, stdio: "ignore", windowsHide: true });
const spawnBrowser = (): ChildProcess => spawn(browserPath, args, { detached: false, stdio: "ignore", windowsHide: true });
let child = spawnBrowser();
child.on("error", () => {
// spawn 失败由下方就绪轮询超时统一报错
});
@ -245,11 +272,24 @@ export async function launchExternalBrowser(options: ExternalLaunchOptions): Pro
// 就绪轮询:等 /json/version 可访问(最长 15s)
const deadline = Date.now() + 15000;
let ready = false;
let retriedStale = false;
while (Date.now() < deadline) {
if (child.exitCode !== null) {
if (!retriedStale) {
// 立即退出(常见代码 0):专用 profile 被残留实例占用,Chrome 转交后自杀;
// 清理残留后重试一次
retriedStale = true;
await killStaleBrowserProcesses(browserType, userDataDir);
await new Promise((resolve) => setTimeout(resolve, 300));
child = spawnBrowser();
child.on("error", () => {
// spawn 失败由下方就绪轮询超时统一报错
});
continue;
}
throw new RuntimeError("browser_error", `${label} 启动失败(进程已退出,代码 ${child.exitCode})`, {
detail: browserPath,
recommendation: "检查浏览器安装是否完整,或参数是否合法"
recommendation: "已尝试清理残留实例仍失败:检查浏览器安装是否完整,或重启电脑后重试"
});
}
if (await isDebugPortReady(port)) {