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:
parent
594377ab79
commit
5f5e8bcb0e
@ -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:// 内部页 */
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user