diff --git a/packages/rpa-core/src/browser/transport.ts b/packages/rpa-core/src/browser/transport.ts index 1893db4..62e1f8f 100644 --- a/packages/rpa-core/src/browser/transport.ts +++ b/packages/rpa-core/src/browser/transport.ts @@ -54,11 +54,16 @@ export class ExternalTransport implements CdpTransport { private handlers = new Map>(); private closedFlag = false; private readonly openPromise: Promise; + // 单命令超时: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((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 { - const transport = new ExternalTransport(wsUrl, description); + static async connect( + wsUrl: string, + description: string, + timeoutMs = 10000, + commandTimeoutMs = 30_000 + ): Promise { + const transport = new ExternalTransport(wsUrl, description, commandTimeoutMs); const timeout = new Promise((_, 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 | undefined; const reply = new Promise((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); } } diff --git a/src/main/browser/selector.ts b/src/main/browser/selector.ts new file mode 100644 index 0000000..33daf4d --- /dev/null +++ b/src/main/browser/selector.ts @@ -0,0 +1,18 @@ +import { RuntimeError } from "@rpa/core"; + +/** + * 浏览器能力统一的元素选择器入参解析(W3 定位器统一修复)。 + * + * 两个浏览器能力文件(browser.ts / browser-extended.ts)此前各持一份 selectorOf, + * 兜底键集合不一致(extended 缺 uiWindowSelector),同一流程里同名参数在 + * 两个能力下行为不同。现收敛为单一事实源,键集合为两者并集。 + */ +export function selectorOf(args: Record): 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; +} diff --git a/src/main/capabilities/browser-dom.ts b/src/main/capabilities/browser-dom.ts index e86649f..d90a403 100644 --- a/src/main/capabilities/browser-dom.ts +++ b/src/main/capabilities/browser-dom.ts @@ -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 { return ctx.browser; } -function selectorOf(args: Record): 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"; } diff --git a/src/main/capabilities/browser-extended.ts b/src/main/capabilities/browser-extended.ts index 345a3af..85506fc 100644 --- a/src/main/capabilities/browser-extended.ts +++ b/src/main/capabilities/browser-extended.ts @@ -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 { return ctx.browser; } -function selectorOf(args: Record): 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"; } diff --git a/src/main/capabilities/browser.ts b/src/main/capabilities/browser.ts index 8ec8cdb..3b9645a 100644 --- a/src/main/capabilities/browser.ts +++ b/src/main/capabilities/browser.ts @@ -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 { @@ -63,16 +64,6 @@ register(["WebBrowser_CreateV3", "WebBrowser_CreateV2", "WebBrowser_Create", "We return { url: await browser.currentUrl(), title: "" }; }); -function selectorOf(args: Record): 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 ?? "");