From 3828d06322035bb5087c836ab1532e53a9ea489a Mon Sep 17 00:00:00 2001 From: fuyua9 Date: Fri, 22 May 2026 15:29:52 +0800 Subject: [PATCH] fix(computer-use-mcp): bound waitForElement frame timeouts (#1856) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary\n- Pass the remaining waitForElement budget into each frame-level CU_ACTION send so unresponsive frames cannot consume the fixed 8s sendMessage timeout.\n- Use the remaining deadline for each poll and stop polling immediately when the budget is exhausted.\n- Reduce the bridge-side waitForElement grace from the legacy 9.5s buffer to a small transport grace.\n- Add a regression test covering the hanging-extension case.\n\n## Validation\n- pnpm -C services/computer-use-mcp exec vitest run --config ./vitest.config.ts src/browser-dom/extension-bridge.test.ts --------- Co-authored-by: Neko Co-authored-by: 刘梓恒 <160735726+3361559784@users.noreply.github.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- packages/i18n/src/locales/es/server/auth.yaml | 2 +- packages/i18n/src/locales/ru/settings.yaml | 4 +- .../i18n/src/locales/zh-Hans/settings.yaml | 2 +- .../chrome-extension/background.js | 97 +++++++++++-------- .../src/browser-dom/extension-bridge.test.ts | 25 +++++ .../src/browser-dom/extension-bridge.ts | 12 +-- 6 files changed, 94 insertions(+), 48 deletions(-) diff --git a/packages/i18n/src/locales/es/server/auth.yaml b/packages/i18n/src/locales/es/server/auth.yaml index f3014c2e8..5c1ddfe5a 100644 --- a/packages/i18n/src/locales/es/server/auth.yaml +++ b/packages/i18n/src/locales/es/server/auth.yaml @@ -66,7 +66,7 @@ signIn: footer: prefix: Al continuar, aceptas nuestros terms: Términos - and: "y" + and: 'y' privacy: Política de Privacidad verifyEmail: title: diff --git a/packages/i18n/src/locales/ru/settings.yaml b/packages/i18n/src/locales/ru/settings.yaml index b77196fb6..6fe73f4bd 100644 --- a/packages/i18n/src/locales/ru/settings.yaml +++ b/packages/i18n/src/locales/ru/settings.yaml @@ -743,7 +743,7 @@ pages: empty: Здесь пока ничего нет. Добавьте одно ниже! add: title: Новый - description: "Заполните новый сервер, затем нажмите кнопку «Сохранить и перезапустить» — он будет перемещен в «Конфигурация» выше." + description: 'Заполните новый сервер, затем нажмите кнопку «Сохранить и перезапустить» — он будет перемещен в «Конфигурация» выше.' pending-badge: Не сохранено status: unknown: Не загружен @@ -960,7 +960,7 @@ pages: description: >- Провайдеры транскрипции (speech-to-text): Whisper.cpp, OpenAI, Azure Speech artistry: - title: Artistry + title: Artistry description: Поставщики моделей генерации и создания изображений, например ComfyUI, Replicate. items: comfyui: diff --git a/packages/i18n/src/locales/zh-Hans/settings.yaml b/packages/i18n/src/locales/zh-Hans/settings.yaml index 0085d65cf..4b59b8f51 100644 --- a/packages/i18n/src/locales/zh-Hans/settings.yaml +++ b/packages/i18n/src/locales/zh-Hans/settings.yaml @@ -743,7 +743,7 @@ pages: empty: 这里什么都还没有哦,在下面添加一个! add: title: 新建 - description: "填写新的服务器配置,然后点击「保存并重启」,完成后将会更新至上方的「已配置」" + description: '填写新的服务器配置,然后点击「保存并重启」,完成后将会更新至上方的「已配置」' pending-badge: 未保存 status: unknown: 未加载 diff --git a/services/computer-use-mcp/chrome-extension/background.js b/services/computer-use-mcp/chrome-extension/background.js index a3b021ea7..b589fd869 100644 --- a/services/computer-use-mcp/chrome-extension/background.js +++ b/services/computer-use-mcp/chrome-extension/background.js @@ -180,11 +180,19 @@ async function getActiveTab() { * Send a CU_ACTION message to a specific tab + frame. * msg_bridge.js (ISOLATED world) receives → postMessage → content.js (MAIN world) */ -async function sendCUAction(tabId, frameId, method, args) { +function resolveActionTimeoutMs(timeoutMs) { + const numericTimeout = Number(timeoutMs) + if (!Number.isFinite(numericTimeout) || numericTimeout <= 0) + return SEND_CU_ACTION_TIMEOUT_MS + + return Math.max(1, Math.min(Math.ceil(numericTimeout), SEND_CU_ACTION_TIMEOUT_MS)) +} + +async function sendCUAction(tabId, frameId, method, args, options = {}) { return new Promise((resolve) => { const timeout = setTimeout(() => { resolve({ success: false, error: 'sendMessage timeout' }) - }, SEND_CU_ACTION_TIMEOUT_MS) + }, resolveActionTimeoutMs(options.timeoutMs)) try { chrome.tabs.sendMessage( @@ -213,7 +221,7 @@ async function sendCUAction(tabId, frameId, method, args) { * Run a CU_ACTION across all frames (or specified frames) in a tab. * Returns [{frameId, result}] */ -async function runCUAction(tabId, frameIds, method, args) { +async function runCUAction(tabId, frameIds, method, args, options = {}) { let targets = frameIds if (!targets || (Array.isArray(targets) && targets.length === 0)) { const frames = await chrome.webNavigation.getAllFrames({ tabId }) @@ -225,7 +233,7 @@ async function runCUAction(tabId, frameIds, method, args) { return Promise.all( targets.map(async (fid) => { - const result = await sendCUAction(tabId, fid, method, args) + const result = await sendCUAction(tabId, fid, method, args, options) return { frameId: fid, result } }), ) @@ -465,9 +473,51 @@ async function handleCommand(cmd) { let lastFrameError = '' result = await new Promise((resolve) => { + async function resolveTimeout() { + if (lastFrames.length === 0) { + let frameIds = [] + if (Array.isArray(cmd.frameIds) && cmd.frameIds.length > 0) { + frameIds = cmd.frameIds + } + else if (typeof cmd.frameIds === 'number') { + frameIds = [cmd.frameIds] + } + else { + try { + const frames = await chrome.webNavigation.getAllFrames({ tabId }) + frameIds = frames.map(frame => frame.frameId) + } + catch { + frameIds = [0] + } + } + lastFrames = frameIds.map(frameId => ({ frameId })) + } + + const lastError = lastPollError || lastFrameError || undefined + resolve(lastFrames.map(entry => ({ + frameId: entry.frameId, + result: { + success: false, + error: `timed out waiting for selector "${selector}"`, + selector, + timeoutMs, + ...(lastError ? { lastError } : {}), + }, + }))) + } + async function poll() { + const remainingMs = deadline - Date.now() + if (remainingMs <= 0) { + await resolveTimeout() + return + } + try { - const frames = await runCUAction(tabId, cmd.frameIds || null, 'findElements', [selector, 1]) + const frames = await runCUAction(tabId, cmd.frameIds || null, 'findElements', [selector, 1], { + timeoutMs: remainingMs, + }) lastFrames = frames const frameErrors = frames .map(entry => unwrapBridgePayload(entry.result)) @@ -490,41 +540,12 @@ async function handleCommand(cmd) { lastPollError = e?.message || String(e) } - if (Date.now() >= deadline) { - if (lastFrames.length === 0) { - let frameIds = [] - if (Array.isArray(cmd.frameIds) && cmd.frameIds.length > 0) { - frameIds = cmd.frameIds - } - else if (typeof cmd.frameIds === 'number') { - frameIds = [cmd.frameIds] - } - else { - try { - const frames = await chrome.webNavigation.getAllFrames({ tabId }) - frameIds = frames.map(frame => frame.frameId) - } - catch { - frameIds = [0] - } - } - lastFrames = frameIds.map(frameId => ({ frameId })) - } - - const lastError = lastPollError || lastFrameError || undefined - resolve(lastFrames.map(entry => ({ - frameId: entry.frameId, - result: { - success: false, - error: `timed out waiting for selector "${selector}"`, - selector, - timeoutMs, - ...(lastError ? { lastError } : {}), - }, - }))) + const nextDelayMs = Math.min(WAIT_FOR_ELEMENT_POLL_INTERVAL_MS, Math.max(0, deadline - Date.now())) + if (nextDelayMs <= 0) { + await resolveTimeout() return } - setTimeout(poll, WAIT_FOR_ELEMENT_POLL_INTERVAL_MS) + setTimeout(poll, nextDelayMs) } poll() }) diff --git a/services/computer-use-mcp/src/browser-dom/extension-bridge.test.ts b/services/computer-use-mcp/src/browser-dom/extension-bridge.test.ts index 0b94734f7..aa7ba8fe8 100644 --- a/services/computer-use-mcp/src/browser-dom/extension-bridge.test.ts +++ b/services/computer-use-mcp/src/browser-dom/extension-bridge.test.ts @@ -237,6 +237,31 @@ describe('browserDomExtensionBridge', () => { ]) }) + it('waitForElement does not keep the legacy full send-timeout buffer when the extension hangs', async () => { + const result = await createConnectedBridge({ requestTimeoutMs: 5_000 }) + bridge = result.bridge + client = result.client + + // The mock extension deliberately does not answer waitForElement. The + // bridge should only keep a small transport grace on top of the requested + // wait budget, not the old 9.5s background send-message buffer. + client.on('message', (raw) => { + const data = JSON.parse(String(raw)) as Record + if (data.action !== 'waitForElement') + return + + expect(data.timeoutMs).toBe(100) + }) + + const startedAt = Date.now() + await expect(bridge.waitForElement({ + selector: '#never-appears', + timeoutMs: 100, + })).rejects.toThrow('browser dom bridge timed out waiting for waitForElement') + + expect(Date.now() - startedAt).toBeLessThan(2_500) + }) + it('waitForElement uses the default requestTimeoutMs for extension-side polling when no timeoutMs is provided', async () => { const result = await createConnectedBridge({ requestTimeoutMs: 200 }) bridge = result.bridge diff --git a/services/computer-use-mcp/src/browser-dom/extension-bridge.ts b/services/computer-use-mcp/src/browser-dom/extension-bridge.ts index 71abdf34f..63f8490a4 100644 --- a/services/computer-use-mcp/src/browser-dom/extension-bridge.ts +++ b/services/computer-use-mcp/src/browser-dom/extension-bridge.ts @@ -23,7 +23,7 @@ const SUPPORTED_ACTIONS = new Set([ 'getComputedStyles', 'waitForElement', ]) -const WAIT_FOR_ELEMENT_BRIDGE_TIMEOUT_BUFFER_MS = 9_500 +const WAIT_FOR_ELEMENT_BRIDGE_TIMEOUT_GRACE_MS = 1_000 interface PendingBridgeRequest { reject: (error: Error) => void @@ -375,17 +375,17 @@ export class BrowserDomExtensionBridge { frameIds?: number[] }) { const effectiveTimeout = params.timeoutMs ?? this.config.requestTimeoutMs - // NOTICE: The bridge-level timeout must exceed the background-level polling - // timeout, otherwise the bridge rejects before the extension finishes polling. - // The extension can overrun by one full frame send timeout (8s) plus the - // polling interval (500ms), so keep headroom for slow or unresponsive frames. + // NOTICE: The bridge-level timeout only needs a small transport grace: the + // extension now passes the remaining waitForElement budget into each + // frame-level send, so slow or unresponsive frames no longer require an + // extra full send-message timeout on top of the requested poll budget. return await this.callAction>>>('waitForElement', { selector: params.selector, timeoutMs: effectiveTimeout, tabId: params.tabId, frameIds: params.frameIds, }, { - timeoutMs: effectiveTimeout + WAIT_FOR_ELEMENT_BRIDGE_TIMEOUT_BUFFER_MS, + timeoutMs: effectiveTimeout + WAIT_FOR_ELEMENT_BRIDGE_TIMEOUT_GRACE_MS, }) }