fix(browser): W3 迁前必修两缺陷——transport 单命令超时 + 定位器统一

目标:ADR-R4 前置修复——①CDP 对端不回包时 send 在有限时间内失败;②同名定位参数在全部网页能力下行为一致
代码逻辑:①ExternalTransport.send 增单命令超时(默认 30s,connect 可传),超时以稳定码 timeout 拒绝并清理 pending(不再依赖引擎级超时兜底+泄漏表项);RuntimeError 原样透传不经 mapCdpError 改写;②selectorOf 三份重复定义(browser/browser-extended/browser-dom)收敛为 src/main/browser/selector.ts 单一事实源,键集合取并集(补 uiWindowSelector);browser-common 的 element_position 位置感知定位语义不同,有意保留
验证:宿主 browser+extended 测试 49/49 全绿;rpa-core 引擎域 32/32 全绿;typecheck 归零
风险与回滚:低——超时默认值对既有快流程无感知;定位器统一仅补齐兜底键,行为为超集
This commit is contained in:
yu 2026-11-06 17:46:06 +08:00
parent 43ae705629
commit 08e798c087
5 changed files with 51 additions and 31 deletions

View File

@ -54,11 +54,16 @@ export class ExternalTransport implements CdpTransport {
private handlers = new Map<string, Set<CdpEventHandler>>();
private closedFlag = false;
private readonly openPromise: Promise<void>;
// 单命令超时:CDP 对端不回包时(页面冻结/debugger 忙/eval 挂起),send 必须在有限时间内失败,
// 否则挂起会占住节点直到引擎级超时,且 pending 表项泄漏。
private readonly commandTimeoutMs: number;
private constructor(
wsUrl: string,
readonly description: string
readonly description: string,
commandTimeoutMs = 30_000
) {
this.commandTimeoutMs = commandTimeoutMs;
this.openPromise = new Promise<void>((resolve, reject) => {
// perMessageDeflate 必须显式关闭:ws 客户端默认开启压缩协商后,若 ws 被打包进
// 主进程 bundle,其可选依赖 bufferutil 的解析产物会破坏 mask fallback,
@ -80,8 +85,13 @@ export class ExternalTransport implements CdpTransport {
});
}
static async connect(wsUrl: string, description: string, timeoutMs = 10000): Promise<ExternalTransport> {
const transport = new ExternalTransport(wsUrl, description);
static async connect(
wsUrl: string,
description: string,
timeoutMs = 10000,
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)
);
@ -101,6 +111,7 @@ export class ExternalTransport implements CdpTransport {
}
const id = this.nextId++;
const ws = this.ws as WebSocket;
let timeoutTimer: ReturnType<typeof setTimeout> | undefined;
const reply = new Promise<unknown>((resolve, reject) => {
this.pending.set(id, { resolve, reject });
try {
@ -108,12 +119,28 @@ export class ExternalTransport implements CdpTransport {
} catch (error) {
this.pending.delete(id);
reject(error as Error);
return;
}
// 单命令超时:以稳定错误码失败并清理 pending,避免对端不回包时永久挂起
timeoutTimer = setTimeout(() => {
if (this.pending.delete(id)) {
reject(
new RuntimeError("timeout", `CDP 命令超时:${method}`, {
detail: `单命令超时上限 ${this.commandTimeoutMs}ms,对端未返回响应`,
recommendation: "检查目标页面是否冻结或弹出了模态对话框;必要时调大节点超时时间"
})
);
}
}, this.commandTimeoutMs);
});
try {
return (await reply) as T;
} catch (error) {
// RuntimeError(如上面的单命令超时)原样透传,不再经 mapCdpError 改写错误码
if (error instanceof RuntimeError) throw error;
throw mapCdpError(error, "external");
} finally {
if (timeoutTimer !== undefined) clearTimeout(timeoutTimer);
}
}

View File

@ -0,0 +1,18 @@
import { RuntimeError } from "@rpa/core";
/**
* 浏览器能力统一的元素选择器入参解析(W3 定位器统一修复)。
*
* 两个浏览器能力文件(browser.ts / browser-extended.ts)此前各持一份 selectorOf,
* 兜底键集合不一致(extended 缺 uiWindowSelector),同一流程里同名参数在
* 两个能力下行为不同。现收敛为单一事实源,键集合为两者并集。
*/
export function selectorOf(args: Record<string, unknown>): unknown {
const selector = args.uiSelector ?? args.selector ?? args.target ?? args.uiWindowSelector;
if (selector === undefined || selector === null || selector === "") {
throw new RuntimeError("validation_error", "元素选择器为空", {
recommendation: "填写 css:#id、text:提交、aria:button=提交 或使用拾取器"
});
}
return selector;
}

View File

@ -1,5 +1,6 @@
import path from "node:path";
import { browserSession } from "../browser/session";
import { selectorOf } from "../browser/selector";
import { register } from "@rpa/core";
import { RuntimeError, type BrowserLike, type RunContext } from "@rpa/core";
@ -21,14 +22,6 @@ async function session(ctx: RunContext): Promise<BrowserLike> {
return ctx.browser;
}
function selectorOf(args: Record<string, unknown>): unknown {
const selector = args.uiSelector ?? args.selector ?? args.target;
if (selector === undefined || selector === null || selector === "") {
throw new RuntimeError("validation_error", "元素选择器为空", { recommendation: "请拾取或填写网页元素" });
}
return selector;
}
function enabled(value: unknown): boolean {
return value === true || value === 1 || String(value).toLowerCase() === "true" || String(value) === "1";
}

View File

@ -1,4 +1,5 @@
import { browserSession } from "../browser/session";
import { selectorOf } from "../browser/selector";
import { register } from "@rpa/core";
import { RuntimeError, type BrowserLike, type RunContext } from "@rpa/core";
@ -24,16 +25,6 @@ async function session(ctx: RunContext): Promise<BrowserLike> {
return ctx.browser;
}
function selectorOf(args: Record<string, unknown>): unknown {
const selector = args.uiSelector ?? args.selector ?? args.target;
if (selector === undefined || selector === null || selector === "") {
throw new RuntimeError("validation_error", "元素选择器为空", {
recommendation: "填写 css:#id、text:提交、aria:button=提交 或使用拾取器"
});
}
return selector;
}
function enabled(value: unknown): boolean {
return value === true || value === 1 || String(value).toLowerCase() === "true" || String(value) === "1";
}

View File

@ -1,6 +1,7 @@
import { register } from "@rpa/core";
import { RuntimeError } from "@rpa/core";
import { browserSession, externalBrowserSession } from "../browser/session";
import { selectorOf } from "../browser/selector";
import type { BrowserLike, RunContext } from "@rpa/core";
async function session(ctx: RunContext): Promise<BrowserLike> {
@ -63,16 +64,6 @@ register(["WebBrowser_CreateV3", "WebBrowser_CreateV2", "WebBrowser_Create", "We
return { url: await browser.currentUrl(), title: "" };
});
function selectorOf(args: Record<string, unknown>): unknown {
const selector = args.uiSelector ?? args.selector ?? args.target ?? args.uiWindowSelector;
if (selector === undefined || selector === null || selector === "") {
throw new RuntimeError("validation_error", "元素选择器为空", {
recommendation: "填写 css:#id、text:提交、aria:button=提交 或使用拾取器"
});
}
return selector;
}
register(["WebBrowser_GoUrl", "WebBrowser_Navigate"], async (ctx, node, args) => {
const browser = await session(ctx);
const url = String(args.url ?? "");