fix(computer-use-mcp): bound waitForElement frame timeouts (#1856)

## 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 <neko@ayaka.moe>
Co-authored-by: 刘梓恒 <160735726+3361559784@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
fuyua9
2026-05-22 15:29:52 +08:00
committed by GitHub
co-authored by Neko 刘梓恒 autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
parent e53515ae8d
commit 3828d06322
6 changed files with 94 additions and 48 deletions
@@ -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:
+2 -2
View File
@@ -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:
@@ -743,7 +743,7 @@ pages:
empty: 这里什么都还没有哦,在下面添加一个!
add:
title: 新建
description: "填写新的服务器配置,然后点击「保存并重启」,完成后将会更新至上方的「已配置」"
description: '填写新的服务器配置,然后点击「保存并重启」,完成后将会更新至上方的「已配置」'
pending-badge: 未保存
status:
unknown: 未加载
@@ -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()
})
@@ -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<string, unknown>
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
@@ -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<Array<BrowserDomFrameResult<Record<string, unknown>>>>('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,
})
}