mirror of
https://github.com/moeru-ai/airi.git
synced 2026-08-14 08:52:42 +00:00
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:
co-authored by
Neko
刘梓恒
autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
parent
e53515ae8d
commit
3828d06322
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user