Skip to content

Commit 3828d06

Browse files
fuyua9nekomeowww3361559784autofix-ci[bot]
authored
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>
1 parent e53515a commit 3828d06

6 files changed

Lines changed: 94 additions & 48 deletions

File tree

packages/i18n/src/locales/es/server/auth.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ signIn:
6666
footer:
6767
prefix: Al continuar, aceptas nuestros
6868
terms: Términos
69-
and: "y"
69+
and: 'y'
7070
privacy: Política de Privacidad
7171
verifyEmail:
7272
title:

packages/i18n/src/locales/ru/settings.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -743,7 +743,7 @@ pages:
743743
empty: Здесь пока ничего нет. Добавьте одно ниже!
744744
add:
745745
title: Новый
746-
description: "Заполните новый сервер, затем нажмите кнопку «Сохранить и перезапустить» — он будет перемещен в «Конфигурация» выше."
746+
description: 'Заполните новый сервер, затем нажмите кнопку «Сохранить и перезапустить» — он будет перемещен в «Конфигурация» выше.'
747747
pending-badge: Не сохранено
748748
status:
749749
unknown: Не загружен
@@ -960,7 +960,7 @@ pages:
960960
description: >-
961961
Провайдеры транскрипции (speech-to-text): Whisper.cpp, OpenAI, Azure Speech
962962
artistry:
963-
title: Artistry
963+
title: Artistry
964964
description: Поставщики моделей генерации и создания изображений, например ComfyUI, Replicate.
965965
items:
966966
comfyui:

packages/i18n/src/locales/zh-Hans/settings.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -743,7 +743,7 @@ pages:
743743
empty: 这里什么都还没有哦,在下面添加一个!
744744
add:
745745
title: 新建
746-
description: "填写新的服务器配置,然后点击「保存并重启」,完成后将会更新至上方的「已配置」"
746+
description: '填写新的服务器配置,然后点击「保存并重启」,完成后将会更新至上方的「已配置」'
747747
pending-badge: 未保存
748748
status:
749749
unknown: 未加载

services/computer-use-mcp/chrome-extension/background.js

Lines changed: 59 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -180,11 +180,19 @@ async function getActiveTab() {
180180
* Send a CU_ACTION message to a specific tab + frame.
181181
* msg_bridge.js (ISOLATED world) receives → postMessage → content.js (MAIN world)
182182
*/
183-
async function sendCUAction(tabId, frameId, method, args) {
183+
function resolveActionTimeoutMs(timeoutMs) {
184+
const numericTimeout = Number(timeoutMs)
185+
if (!Number.isFinite(numericTimeout) || numericTimeout <= 0)
186+
return SEND_CU_ACTION_TIMEOUT_MS
187+
188+
return Math.max(1, Math.min(Math.ceil(numericTimeout), SEND_CU_ACTION_TIMEOUT_MS))
189+
}
190+
191+
async function sendCUAction(tabId, frameId, method, args, options = {}) {
184192
return new Promise((resolve) => {
185193
const timeout = setTimeout(() => {
186194
resolve({ success: false, error: 'sendMessage timeout' })
187-
}, SEND_CU_ACTION_TIMEOUT_MS)
195+
}, resolveActionTimeoutMs(options.timeoutMs))
188196

189197
try {
190198
chrome.tabs.sendMessage(
@@ -213,7 +221,7 @@ async function sendCUAction(tabId, frameId, method, args) {
213221
* Run a CU_ACTION across all frames (or specified frames) in a tab.
214222
* Returns [{frameId, result}]
215223
*/
216-
async function runCUAction(tabId, frameIds, method, args) {
224+
async function runCUAction(tabId, frameIds, method, args, options = {}) {
217225
let targets = frameIds
218226
if (!targets || (Array.isArray(targets) && targets.length === 0)) {
219227
const frames = await chrome.webNavigation.getAllFrames({ tabId })
@@ -225,7 +233,7 @@ async function runCUAction(tabId, frameIds, method, args) {
225233

226234
return Promise.all(
227235
targets.map(async (fid) => {
228-
const result = await sendCUAction(tabId, fid, method, args)
236+
const result = await sendCUAction(tabId, fid, method, args, options)
229237
return { frameId: fid, result }
230238
}),
231239
)
@@ -465,9 +473,51 @@ async function handleCommand(cmd) {
465473
let lastFrameError = ''
466474

467475
result = await new Promise((resolve) => {
476+
async function resolveTimeout() {
477+
if (lastFrames.length === 0) {
478+
let frameIds = []
479+
if (Array.isArray(cmd.frameIds) && cmd.frameIds.length > 0) {
480+
frameIds = cmd.frameIds
481+
}
482+
else if (typeof cmd.frameIds === 'number') {
483+
frameIds = [cmd.frameIds]
484+
}
485+
else {
486+
try {
487+
const frames = await chrome.webNavigation.getAllFrames({ tabId })
488+
frameIds = frames.map(frame => frame.frameId)
489+
}
490+
catch {
491+
frameIds = [0]
492+
}
493+
}
494+
lastFrames = frameIds.map(frameId => ({ frameId }))
495+
}
496+
497+
const lastError = lastPollError || lastFrameError || undefined
498+
resolve(lastFrames.map(entry => ({
499+
frameId: entry.frameId,
500+
result: {
501+
success: false,
502+
error: `timed out waiting for selector "${selector}"`,
503+
selector,
504+
timeoutMs,
505+
...(lastError ? { lastError } : {}),
506+
},
507+
})))
508+
}
509+
468510
async function poll() {
511+
const remainingMs = deadline - Date.now()
512+
if (remainingMs <= 0) {
513+
await resolveTimeout()
514+
return
515+
}
516+
469517
try {
470-
const frames = await runCUAction(tabId, cmd.frameIds || null, 'findElements', [selector, 1])
518+
const frames = await runCUAction(tabId, cmd.frameIds || null, 'findElements', [selector, 1], {
519+
timeoutMs: remainingMs,
520+
})
471521
lastFrames = frames
472522
const frameErrors = frames
473523
.map(entry => unwrapBridgePayload(entry.result))
@@ -490,41 +540,12 @@ async function handleCommand(cmd) {
490540
lastPollError = e?.message || String(e)
491541
}
492542

493-
if (Date.now() >= deadline) {
494-
if (lastFrames.length === 0) {
495-
let frameIds = []
496-
if (Array.isArray(cmd.frameIds) && cmd.frameIds.length > 0) {
497-
frameIds = cmd.frameIds
498-
}
499-
else if (typeof cmd.frameIds === 'number') {
500-
frameIds = [cmd.frameIds]
501-
}
502-
else {
503-
try {
504-
const frames = await chrome.webNavigation.getAllFrames({ tabId })
505-
frameIds = frames.map(frame => frame.frameId)
506-
}
507-
catch {
508-
frameIds = [0]
509-
}
510-
}
511-
lastFrames = frameIds.map(frameId => ({ frameId }))
512-
}
513-
514-
const lastError = lastPollError || lastFrameError || undefined
515-
resolve(lastFrames.map(entry => ({
516-
frameId: entry.frameId,
517-
result: {
518-
success: false,
519-
error: `timed out waiting for selector "${selector}"`,
520-
selector,
521-
timeoutMs,
522-
...(lastError ? { lastError } : {}),
523-
},
524-
})))
543+
const nextDelayMs = Math.min(WAIT_FOR_ELEMENT_POLL_INTERVAL_MS, Math.max(0, deadline - Date.now()))
544+
if (nextDelayMs <= 0) {
545+
await resolveTimeout()
525546
return
526547
}
527-
setTimeout(poll, WAIT_FOR_ELEMENT_POLL_INTERVAL_MS)
548+
setTimeout(poll, nextDelayMs)
528549
}
529550
poll()
530551
})

services/computer-use-mcp/src/browser-dom/extension-bridge.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,31 @@ describe('browserDomExtensionBridge', () => {
237237
])
238238
})
239239

240+
it('waitForElement does not keep the legacy full send-timeout buffer when the extension hangs', async () => {
241+
const result = await createConnectedBridge({ requestTimeoutMs: 5_000 })
242+
bridge = result.bridge
243+
client = result.client
244+
245+
// The mock extension deliberately does not answer waitForElement. The
246+
// bridge should only keep a small transport grace on top of the requested
247+
// wait budget, not the old 9.5s background send-message buffer.
248+
client.on('message', (raw) => {
249+
const data = JSON.parse(String(raw)) as Record<string, unknown>
250+
if (data.action !== 'waitForElement')
251+
return
252+
253+
expect(data.timeoutMs).toBe(100)
254+
})
255+
256+
const startedAt = Date.now()
257+
await expect(bridge.waitForElement({
258+
selector: '#never-appears',
259+
timeoutMs: 100,
260+
})).rejects.toThrow('browser dom bridge timed out waiting for waitForElement')
261+
262+
expect(Date.now() - startedAt).toBeLessThan(2_500)
263+
})
264+
240265
it('waitForElement uses the default requestTimeoutMs for extension-side polling when no timeoutMs is provided', async () => {
241266
const result = await createConnectedBridge({ requestTimeoutMs: 200 })
242267
bridge = result.bridge

services/computer-use-mcp/src/browser-dom/extension-bridge.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ const SUPPORTED_ACTIONS = new Set([
2323
'getComputedStyles',
2424
'waitForElement',
2525
])
26-
const WAIT_FOR_ELEMENT_BRIDGE_TIMEOUT_BUFFER_MS = 9_500
26+
const WAIT_FOR_ELEMENT_BRIDGE_TIMEOUT_GRACE_MS = 1_000
2727

2828
interface PendingBridgeRequest {
2929
reject: (error: Error) => void
@@ -375,17 +375,17 @@ export class BrowserDomExtensionBridge {
375375
frameIds?: number[]
376376
}) {
377377
const effectiveTimeout = params.timeoutMs ?? this.config.requestTimeoutMs
378-
// NOTICE: The bridge-level timeout must exceed the background-level polling
379-
// timeout, otherwise the bridge rejects before the extension finishes polling.
380-
// The extension can overrun by one full frame send timeout (8s) plus the
381-
// polling interval (500ms), so keep headroom for slow or unresponsive frames.
378+
// NOTICE: The bridge-level timeout only needs a small transport grace: the
379+
// extension now passes the remaining waitForElement budget into each
380+
// frame-level send, so slow or unresponsive frames no longer require an
381+
// extra full send-message timeout on top of the requested poll budget.
382382
return await this.callAction<Array<BrowserDomFrameResult<Record<string, unknown>>>>('waitForElement', {
383383
selector: params.selector,
384384
timeoutMs: effectiveTimeout,
385385
tabId: params.tabId,
386386
frameIds: params.frameIds,
387387
}, {
388-
timeoutMs: effectiveTimeout + WAIT_FOR_ELEMENT_BRIDGE_TIMEOUT_BUFFER_MS,
388+
timeoutMs: effectiveTimeout + WAIT_FOR_ELEMENT_BRIDGE_TIMEOUT_GRACE_MS,
389389
})
390390
}
391391

0 commit comments

Comments
 (0)