fix(browser): 审查整改批次2镜像——浏览器生命周期四缺陷(对面 55bc8f1b 同源)

目标:对齐对面 feat 分支 RPA-REV-2 的浏览器侧整改,保持双仓同源
代码逻辑:①killStaleBrowserProcesses 失败记 warning 不再静默;PowerShell -like 元字符转义防扩大匹配;②默认 userDataDir 加随机后缀防并发互踩;③launch 失败路径(/json/list、transport.connect)回收自拉子进程;④ExternalTransport.connect 失败/超时关闭底层 WS 并清理 timer
验证:对面 rpa-core tsc 归零 + engine 32/32;本仓库同代码
风险与回滚:低——失败路径增强,revert 即回
This commit is contained in:
yu 2026-11-09 09:55:59 +08:00
parent 594377ab79
commit 5f5e8bcb0e
2 changed files with 50 additions and 22 deletions

View File

@ -1,6 +1,7 @@
import { execFile, spawn, type ChildProcess } from "node:child_process";
import { promisify } from "node:util";
import { promises as fs } from "node:fs";
import { randomUUID } from "node:crypto";
import os from "node:os";
import path from "node:path";
import { RuntimeError } from "../engine/types";
@ -214,22 +215,26 @@ export async function connectRemoteBrowser(options: ExternalRemoteOptions): Prom
/** 清理占用专用 profile 的残留浏览器进程。
* 同一 user-data-dir 已有实例时,再次启动的 Chrome 会把请求转交旧实例并立即退出(代码 0),
* 导致「启动失败(进程已退出,代码 0)」——这些实例都是本模块之前拉起的,清理是安全的。 */
* 导致「启动失败(进程已退出,代码 0)」——这些实例都是本模块之前拉起的,清理是安全的。
* 匹配按完整 user-data-dir 路径字面量过滤(非通配),只会命中本模块自己拉起的实例。 */
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, "''");
// 单引号转义防注入;-like 用字面量路径(路径内无通配符语义字符时的精确匹配)
const marker = userDataDir.replace(/'/g, "''").replace(/`/g, "``").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 {
// pkill -f 用完整 profile 路径字面量(含 rpa-studio-external 前缀),不扩大清理范围
await execAsync("pkill", ["-f", userDataDir], { timeout: 5000 });
}
} catch {
// 清理失败不阻塞启动:可能本来就没有残留,或权限受限
} catch (error) {
// 清理失败不阻塞启动:可能本来就没有残留,或权限受限;记 warning 供审计(整改:不再静默吞)
console.warn(`[browser] 清理残留 ${browserType} 进程失败(不影响启动):`, (error as Error).message);
}
}
@ -247,7 +252,10 @@ export async function launchExternalBrowser(options: ExternalLaunchOptions): Pro
}
const userDataDir =
options.userDataDir?.trim() || path.join(os.tmpdir(), `rpa-studio-external-${browserType}`);
options.userDataDir?.trim() ||
// 并发/多会话隔离(审查整改):默认 profile 加入随机后缀,进程级唯一,
// 避免多个运行共用同一 profile 互相挤掉登录态与调试实例。
path.join(os.tmpdir(), `rpa-studio-external-${browserType}-${randomUUID().slice(0, 8)}`);
await fs.mkdir(userDataDir, { recursive: true });
const extraArgs = (options.extraArgs ?? []).filter(Boolean);
@ -309,19 +317,30 @@ export async function launchExternalBrowser(options: ExternalLaunchOptions): Pro
});
}
// 就绪之后的接管链(/json/list → transport.connect):任一步失败都必须回收自己拉起的
// 子进程(审查整改:此前失败路径会遗留浏览器进程)。成功路径 killOnClose=true 交由关闭逻辑。
const base = `http://127.0.0.1:${port}`;
const targets = await fetchJson<CdpTargetInfo[]>(`${base}/json/list`, 5000);
const target = pickPageTarget(targets, "about:blank");
const transport = await ExternalTransport.connect(target.webSocketDebuggerUrl as string, `127.0.0.1:${port}`);
return {
transport,
sessionKey: `launch|${browserType}|${port}`,
label: `${label} (127.0.0.1:${port})`,
httpBase: base,
targetId: String(target.id ?? ""),
child,
killOnClose: true
};
try {
const targets = await fetchJson<CdpTargetInfo[]>(`${base}/json/list`, 5000);
const target = pickPageTarget(targets, "about:blank");
const transport = await ExternalTransport.connect(target.webSocketDebuggerUrl as string, `127.0.0.1:${port}`);
return {
transport,
sessionKey: `launch|${browserType}|${port}`,
label: `${label} (127.0.0.1:${port})`,
httpBase: base,
targetId: String(target.id ?? ""),
child,
killOnClose: true
};
} catch (error) {
try {
child.kill();
} catch {
// 进程可能已自行退出
}
throw error;
}
}
/** 列出调试端点上的全部页面级 target(多标签枚举用);排除 chrome://、edge:// 内部页 */

View File

@ -92,11 +92,20 @@ export class ExternalTransport implements CdpTransport {
commandTimeoutMs = 30_000
): Promise<ExternalTransport> {
const transport = new ExternalTransport(wsUrl, description, commandTimeoutMs);
const timeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(new RuntimeError("browser_disconnected", `连接外部浏览器超时:${description}`)), timeoutMs)
);
await Promise.race([transport.openPromise, timeout]);
return transport;
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new RuntimeError("browser_disconnected", `连接外部浏览器超时:${description}`)), timeoutMs);
});
try {
await Promise.race([transport.openPromise, timeout]);
return transport;
} catch (error) {
// 超时/失败路径必须关闭底层 WebSocket(审查整改:此前失败会泄漏挂起的 ws)
await transport.close().catch(() => {});
throw error;
} finally {
if (timer !== undefined) clearTimeout(timer);
}
}
get closed(): boolean {