📋 问题概述
问题现象
在 Docker 容器环境中使用浏览器工具时,频繁出现超时错误:
[tools] browser failed: Can't reach the OpenClaw browser control service(timed out after 15000ms)
环境信息
「部署方式」:Docker Compose(openclaw-gateway + openclaw-sandbox-browser) 「网络拓扑」:各个容器间通过 Docker 内网通信 「CDP 地址」: http://172.20.0.4:9222(容器 IP)「Bridge 地址」: http://172.20.0.2:39893(Gateway 容器 IP)
🔍 问题排查过程
阶段 1:定位超时环节
通过在关键路径添加日志:
「src/browser/client-fetch.ts」
export async function fetchBrowserJson<T>(...): Promise<T> {console.log(`[fetchBrowserJson] === START ===`);console.log(`[fetchBrowserJson] Called with URL: ${url}`);console.log(`[fetchBrowserJson] timeoutMs: ${timeoutMs}`);// ... 其他日志}
「发现」:请求在 openTab 阶段超时。
阶段 2:追踪 openTab 执行路径
在 src/browser/server-context.tab-ops.ts 添加日志:
const openTab = async (url: string): Promise<BrowserTab> => {console.log(`[openTab] Opening URL: ${url}`);console.log(`[openTab] Profile: ${profile.name}, cdpUrl: ${profile.cdpUrl}, isLoopback: ${profile.cdpIsLoopback}`);console.log(`[openTab] Checking capabilities: usesChromeMcp=${capabilities.usesChromeMcp}, usesPersistentPlaywright=${capabilities.usesPersistentPlaywright}`);// ...}
「关键发现」:
[openTab] cdpUrl: http://172.20.0.4:9222, isLoopback: false[openTab] usesPersistentPlaywright: true
阶段 3:定位 Playwright 超时原因
查看 profile-capabilities.ts 的判断逻辑:
if (!profile.cdpIsLoopback) {return {mode: "remote-cdp",usesPersistentPlaywright: true, // ← 触发 Playwright 路径};}
查看 pw-session.ts 的超时配置:
async function connectBrowser(cdpUrl: string): Promise<ConnectedBrowser> {for (let attempt = 0; attempt < 3; attempt += 1) {const timeout = 5000 + attempt * 2000; // 5s + 7s + 9s = 21sconst browser = await chromium.connectOverCDP(endpoint, { timeout, headers });}}
「根本原因」:
「容器 IP 不是 loopback」 → cdpIsLoopback = false「触发 Playwright 路径」 → usesPersistentPlaywright = true「Playwright 连接重试总时长」:21秒 「外层 HTTP 请求超时」:15秒 「必然超时失败」
✅ 解决方案
方案概述
为 CDP 和 Playwright 操作增加动态超时配置,根据连接类型(本地/远程)使用不同的超时时间。
修改 1:增加远程超时常量
「文件:src/browser/cdp-timeouts.ts」
export const CDP_HTTP_REQUEST_TIMEOUT_MS = 1500;export const CDP_WS_HANDSHAKE_TIMEOUT_MS = 5000;export const CDP_JSON_NEW_TIMEOUT_MS = 1500;+export const CDP_JSON_NEW_REMOTE_TIMEOUT_MS = 15000; // 容器/远程环境
修改 2:支持超时参数传递
「文件:src/browser/cdp.ts」
export async function createTargetViaCdp(opts: {cdpUrl: string;url: string;ssrfPolicy?: SsrFPolicy;+ timeoutMs?: number;}): Promise<{ targetId: string }> {+ console.log(`[createTargetViaCdp] cdpUrl: ${opts.cdpUrl}, timeoutMs: ${opts.timeoutMs}`);let wsUrl: string;if (isWebSocketUrl(opts.cdpUrl)) {wsUrl = opts.cdpUrl;} else {+ const versionTimeout = opts.timeoutMs ?? 1500;const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(appendCdpPath(opts.cdpUrl, "/json/version"),- 1500,+ versionTimeout,);// ...}+ const handshakeTimeout = opts.timeoutMs ? Math.max(opts.timeoutMs * 2, 5000) : 5000;- return await withCdpSocket(wsUrl, async (send) => {+ return await withCdpSocket(wsUrl, async (send) => {// ...- });+ }, { handshakeTimeoutMs: handshakeTimeout });}
修改 3:优化 Playwright 超时逻辑
「文件:src/browser/pw-session.ts」
export async function createPageViaPlaywright(opts: {cdpUrl: string;url: string;ssrfPolicy?: SsrFPolicy;+ timeoutMs?: number;}): Promise<{...}> {+ console.log(`[createPageViaPlaywright] Starting, timeoutMs: ${opts.timeoutMs}`);- const { browser } = await connectBrowser(opts.cdpUrl);+ const connectTimeout = opts.timeoutMs ? Math.floor(opts.timeoutMs * 0.4) : undefined;+ const { browser } = await connectBrowser(opts.cdpUrl, connectTimeout);// ...+ const gotoTimeout = opts.timeoutMs ? Math.floor(opts.timeoutMs * 0.5) : 30_000;- const response = await page.goto(targetUrl, { timeout: 30_000 });+ const response = await page.goto(targetUrl, { timeout: gotoTimeout });}-async function connectBrowser(cdpUrl: string): Promise<ConnectedBrowser> {+async function connectBrowser(cdpUrl: string, timeoutMs?: number): Promise<ConnectedBrowser> {// ...const connectWithRetry = async (): Promise<ConnectedBrowser> => {let lastErr: unknown;+ const maxAttempts = timeoutMs ? 1 : 3; // 指定超时时不重试- for (let attempt = 0; attempt < 3; attempt += 1) {+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {try {- const timeout = 5000 + attempt * 2000;+ const timeout = timeoutMs ?? (5000 + attempt * 2000);// ...}}}}
修改 4:传递超时参数
「文件:src/browser/server-context.tab-ops.ts」
+import { CDP_JSON_NEW_TIMEOUT_MS, CDP_JSON_NEW_REMOTE_TIMEOUT_MS } from "./cdp-timeouts.js";const openTab = async (url: string): Promise<BrowserTab> => {+ // 提前计算超时时间(Playwright 和 CDP 分支都会用到)+ const cdpNewTimeout = profile.cdpIsLoopback+ ? CDP_JSON_NEW_TIMEOUT_MS+ : CDP_JSON_NEW_REMOTE_TIMEOUT_MS;+ console.log(`[openTab] CDP timeout: ${cdpNewTimeout}ms (isLoopback: ${profile.cdpIsLoopback})`);const ssrfPolicyOpts = withBrowserNavigationPolicy(state().resolved.ssrfPolicy);if (capabilities.usesChromeMcp) {// ... Chrome MCP 路径}if (capabilities.usesPersistentPlaywright) {const mod = await getPwAiModule({ mode: "strict" });const createPageViaPlaywright = (mod as Partial<PwAiModule> | null)?.createPageViaPlaywright;if (typeof createPageViaPlaywright === "function") {const page = await createPageViaPlaywright({cdpUrl: profile.cdpUrl,url,...ssrfPolicyOpts,+ timeoutMs: cdpNewTimeout, // 传递超时参数});// ...}}// CDP 路径const createdViaCdp = await createTargetViaCdp({cdpUrl: profile.cdpUrl,url,...ssrfPolicyOpts,+ timeoutMs: cdpNewTimeout, // 传递超时参数});// ... /json/new 路径- const created = await fetchJson<CdpTarget>(endpoint, CDP_JSON_NEW_TIMEOUT_MS, {+ const created = await fetchJson<CdpTarget>(endpoint, cdpNewTimeout, {method: "PUT",}).catch(async (err) => {if (String(err).includes("HTTP 405")) {- return await fetchJson<CdpTarget>(endpoint, CDP_JSON_NEW_TIMEOUT_MS);+ return await fetchJson<CdpTarget>(endpoint, cdpNewTimeout);}throw err;});}
📊 效果对比
修改前
[openTab] cdpUrl: http://172.20.0.4:9222, isLoopback: false[openTab] usesPersistentPlaywright: true[connectBrowser] Attempt 1/3, timeout: 5000ms[connectBrowser] Attempt 2/3, timeout: 7000ms[connectBrowser] Attempt 3/3, timeout: 9000ms... (总计 21 秒,超过 15 秒超时)[tools] browser failed: timed out after 15000ms
修改后
[openTab] CDP timeout: 15000ms (isLoopback: false)[openTab] usesPersistentPlaywright: true[createPageViaPlaywright] Starting, timeoutMs: 15000[createPageViaPlaywright] Connecting to browser, timeout: 6000ms[connectBrowser] cdpUrl: http://172.20.0.4:9222, maxAttempts: 1[connectBrowser] Attempt 1/1, timeout: 6000ms[connectBrowser] ✅ Connected successfully[createPageViaPlaywright] Navigating to http://123571.xyz, timeout: 7500ms[createPageViaPlaywright] ✅ Page created: targetId=44438DD2EBF4275DF26C8EBF37C0BF9E[openTab] ✅ Playwright page created
「超时时间分配(15 秒总预算):」
连接浏览器:6秒(40%) 页面导航:7.5秒(50%) 其他开销:1.5秒(10%)
🎯 关键技术点
1. 超时时间分配策略
// 连接超时:总预算的 40%const connectTimeout = timeoutMs * 0.4;// 导航超时:总预算的 50%const gotoTimeout = timeoutMs * 0.5;// WebSocket 握手:总预算的 2倍(或最少 5秒)const handshakeTimeout = Math.max(timeoutMs * 2, 5000);
2. 重试策略优化
// 有明确超时要求时,不重试(避免累积延迟)const maxAttempts = timeoutMs ? 1 : 3;
3. 日志追踪
在关键路径添加结构化日志:
[openTab]- 顶层操作[createPageViaPlaywright]- Playwright 路径[createTargetViaCdp]- CDP 路径[connectBrowser]- 浏览器连接
🔧 可选优化方案
方案 A:容器内强制使用 CDP 路径
「适用场景」:容器间通信稳定、不需要 Playwright 特性
「文件:src/agents/sandbox/browser.ts」
import { isRunningInContainer } from "./docker.js";function buildSandboxBrowserResolvedConfig(params: {...}): ResolvedBrowserConfig {const cdpHost = params.cdpHost;const isStandardLoopback = cdpHost === "127.0.0.1" || cdpHost === "::1" || cdpHost === "localhost";// 容器内的 CDP 连接视为本地快速连接,避免使用 Playwrightconst cdpIsLoopback = isStandardLoopback || isRunningInContainer();// ...}
「优点」:
更快的响应速度(避免 Playwright 开销) 更简单的执行路径
「缺点」:
失去 Playwright 的导航检测能力 不适用于真实远程 CDP
方案 B:智能判断网络类型
function isPrivateNetwork(host: string): boolean {if (host.startsWith("172.")) return true;if (host.startsWith("10.")) return true;if (host.startsWith("192.168.")) return true;return false;}const inContainerPrivateNetwork = isRunningInContainer() && isPrivateNetwork(cdpHost);const cdpIsLoopback = isStandardLoopback || inContainerPrivateNetwork;
「优点」:
容器内私有网络 → 使用 CDP 容器内公网 IP → 使用 Playwright 兼顾速度和功能
📚 相关文件清单
src/browser/cdp-timeouts.ts | |
src/browser/cdp.ts | createTargetViaCdptimeoutMs |
src/browser/pw-session.ts | createPageViaPlaywrightconnectBrowser 支持超时 |
src/browser/server-context.tab-ops.ts | |
src/browser/client-fetch.ts |
✅ 验证清单
[x] 容器环境下浏览器工具不再超时 [x] CDP 路径使用 15 秒超时 [x] Playwright 路径合理分配超时时间 [x] 日志完整可追踪 [x] 不影响本地开发环境
📖 经验总结
「超时配置应该分层」:网络层、连接层、操作层各有独立超时 「容器环境需要特殊处理」:IP 判断不能简单依赖 loopback 「重试策略要合理」:有明确超时要求时,重试会导致累积延迟 「日志是排查利器」:结构化日志 + 标签前缀便于过滤 「超时时间要留余地」:总预算不能全部分配完,需要预留 10% 开销
🔗 附录:完整代码差异
A. cdp-timeouts.ts
export const CDP_HTTP_REQUEST_TIMEOUT_MS = 1500;export const CDP_WS_HANDSHAKE_TIMEOUT_MS = 5000;export const CDP_JSON_NEW_TIMEOUT_MS = 1500;export const CDP_JSON_NEW_REMOTE_TIMEOUT_MS = 15000; // 新增:容器/远程环境export const CHROME_REACHABILITY_TIMEOUT_MS = 500;
B. cdp.ts 核心修改
export async function createTargetViaCdp(opts: {cdpUrl: string;url: string;ssrfPolicy?: SsrFPolicy;timeoutMs?: number; // 新增参数}): Promise<{ targetId: string }> {console.log(`[createTargetViaCdp] cdpUrl: ${opts.cdpUrl}, timeoutMs: ${opts.timeoutMs}`);await assertBrowserNavigationAllowed({url: opts.url,...withBrowserNavigationPolicy(opts.ssrfPolicy),});let wsUrl: string;if (isWebSocketUrl(opts.cdpUrl)) {console.log(`[createTargetViaCdp] Using direct WS URL`);wsUrl = opts.cdpUrl;} else {const versionTimeout = opts.timeoutMs ?? 1500;console.log(`[createTargetViaCdp] Fetching /json/version, timeout: ${versionTimeout}ms`);const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(appendCdpPath(opts.cdpUrl, "/json/version"),versionTimeout,);const wsUrlRaw = String(version?.webSocketDebuggerUrl ?? "").trim();wsUrl = wsUrlRaw ? normalizeCdpWsUrl(wsUrlRaw, opts.cdpUrl) : "";if (!wsUrl) {console.error(`[createTargetViaCdp] No webSocketDebuggerUrl`);throw new Error("CDP /json/version missing webSocketDebuggerUrl");}console.log(`[createTargetViaCdp] WebSocket URL: ${wsUrl}`);}const handshakeTimeout = opts.timeoutMs ? Math.max(opts.timeoutMs * 2, 5000) : 5000;console.log(`[createTargetViaCdp] Opening WebSocket with handshake timeout: ${handshakeTimeout}ms`);return await withCdpSocket(wsUrl, async (send) => {const created = (await send("Target.createTarget", { url: opts.url })) as {targetId?: string;};const targetId = String(created?.targetId ?? "").trim();if (!targetId) {console.error(`[createTargetViaCdp] No targetId in response`);throw new Error("CDP Target.createTarget returned no targetId");}console.log(`[createTargetViaCdp] ✅ Created targetId: ${targetId}`);return { targetId };}, { handshakeTimeoutMs: handshakeTimeout });}
C. pw-session.ts 核心修改
async function connectBrowser(cdpUrl: string, timeoutMs?: number): Promise<ConnectedBrowser> {const normalized = normalizeCdpUrl(cdpUrl);const cached = cachedByCdpUrl.get(normalized);if (cached) {return cached;}const connecting = connectingByCdpUrl.get(normalized);if (connecting) {return await connecting;}const connectWithRetry = async (): Promise<ConnectedBrowser> => {let lastErr: unknown;const maxAttempts = timeoutMs ? 1 : 3; // 指定超时时不重试console.log(`[connectBrowser] cdpUrl: ${normalized}, timeoutMs: ${timeoutMs}, maxAttempts: ${maxAttempts}`);for (let attempt = 0; attempt < maxAttempts; attempt += 1) {try {const timeout = timeoutMs ?? (5000 + attempt * 2000);console.log(`[connectBrowser] Attempt ${attempt + 1}/${maxAttempts}, timeout: ${timeout}ms`);const wsUrl = await getChromeWebSocketUrl(normalized, timeout).catch(() => null);const endpoint = wsUrl ?? normalized;const headers = getHeadersWithAuth(endpoint);const browser = await withNoProxyForCdpUrl(endpoint, () =>chromium.connectOverCDP(endpoint, { timeout, headers }),);const onDisconnected = () => {const current = cachedByCdpUrl.get(normalized);if (current?.browser === browser) {cachedByCdpUrl.delete(normalized);}};const connected: ConnectedBrowser = { browser, cdpUrl: normalized, onDisconnected };cachedByCdpUrl.set(normalized, connected);browser.on("disconnected", onDisconnected);observeBrowser(browser);console.log(`[connectBrowser] ✅ Connected successfully`);return connected;} catch (err) {console.error(`[connectBrowser] Attempt ${attempt + 1} failed:`, err);lastErr = err;const errMsg = err instanceof Error ? err.message : String(err);if (errMsg.includes("rate limit")) {break;}}}throw lastErr;};const promise = connectWithRetry();connectingByCdpUrl.set(normalized, promise);try {return await promise;} finally {connectingByCdpUrl.delete(normalized);}}export async function createPageViaPlaywright(opts: {cdpUrl: string;url: string;ssrfPolicy?: SsrFPolicy;timeoutMs?: number;}): Promise<{targetId: string;title: string;url: string;type: string;}> {console.log(`[createPageViaPlaywright] Starting, cdpUrl: ${opts.cdpUrl}, timeoutMs: ${opts.timeoutMs}`);const connectTimeout = opts.timeoutMs ? Math.floor(opts.timeoutMs * 0.4) : undefined;console.log(`[createPageViaPlaywright] Connecting to browser, timeout: ${connectTimeout}ms`);const { browser } = await connectBrowser(opts.cdpUrl, connectTimeout);const context = browser.contexts()[0] ?? (await browser.newContext());ensureContextState(context);const page = await context.newPage();ensurePageState(page);const targetUrl = opts.url.trim() || "about:blank";if (targetUrl !== "about:blank") {const navigationPolicy = withBrowserNavigationPolicy(opts.ssrfPolicy);await assertBrowserNavigationAllowed({url: targetUrl,...navigationPolicy,});const gotoTimeout = opts.timeoutMs ? Math.floor(opts.timeoutMs * 0.5) : 30_000;console.log(`[createPageViaPlaywright] Navigating to ${targetUrl}, timeout: ${gotoTimeout}ms`);const response = await page.goto(targetUrl, { timeout: gotoTimeout }).catch((err) => {console.error(`[createPageViaPlaywright] Navigation failed:`, err);return null;});await assertBrowserNavigationRedirectChainAllowed({request: response?.request(),...navigationPolicy,});await assertBrowserNavigationResultAllowed({url: page.url(),...navigationPolicy,});}const tid = await pageTargetId(page).catch(() => null);if (!tid) {console.error(`[createPageViaPlaywright] Failed to get targetId`);throw new Error("Failed to get targetId for new page");}console.log(`[createPageViaPlaywright] ✅ Page created: targetId=${tid}`);return {targetId: tid,title: await page.title().catch(() => ""),url: page.url(),type: "page",};}
D. server-context.tab-ops.ts 核心修改
import { CDP_JSON_NEW_TIMEOUT_MS, CDP_JSON_NEW_REMOTE_TIMEOUT_MS } from "./cdp-timeouts.js";const openTab = async (url: string): Promise<BrowserTab> => {console.log(`[openTab] Opening URL: ${url}`);console.log(`[openTab] Profile: ${profile.name}, cdpUrl: ${profile.cdpUrl}, isLoopback: ${profile.cdpIsLoopback}`);// 提前计算超时时间(Playwright 和 CDP 分支都会用到)const cdpNewTimeout = profile.cdpIsLoopback? CDP_JSON_NEW_TIMEOUT_MS: CDP_JSON_NEW_REMOTE_TIMEOUT_MS;console.log(`[openTab] CDP timeout: ${cdpNewTimeout}ms (isLoopback: ${profile.cdpIsLoopback})`);const ssrfPolicyOpts = withBrowserNavigationPolicy(state().resolved.ssrfPolicy);console.log(`[openTab] Checking capabilities: usesChromeMcp=${capabilities.usesChromeMcp}, usesPersistentPlaywright=${capabilities.usesPersistentPlaywright}`);if (capabilities.usesChromeMcp) {await assertBrowserNavigationAllowed({ url, ...ssrfPolicyOpts });const page = await openChromeMcpTab(profile.name, url);const profileState = getProfileState();profileState.lastTargetId = page.targetId;await assertBrowserNavigationResultAllowed({ url: page.url, ...ssrfPolicyOpts });return page;}if (capabilities.usesPersistentPlaywright) {console.log(`[openTab] Checking Playwright module...`);const mod = await getPwAiModule({ mode: "strict" });console.log(`[openTab] Playwright module obtained:`, !!mod);const createPageViaPlaywright = (mod as Partial<PwAiModule> | null)?.createPageViaPlaywright;console.log(`[openTab] createPageViaPlaywright function available:`, !!createPageViaPlaywright);if (typeof createPageViaPlaywright === "function") {console.log(`[openTab] Using Playwright to create page`);const page = await createPageViaPlaywright({cdpUrl: profile.cdpUrl,url,...ssrfPolicyOpts,timeoutMs: cdpNewTimeout, // 传递超时参数});console.log(`[openTab] ✅ Playwright page created: targetId=${page.targetId}, url=${page.url}`);const profileState = getProfileState();profileState.lastTargetId = page.targetId;triggerManagedTabLimit(page.targetId);return {targetId: page.targetId,title: page.title,url: page.url,type: page.type,};}console.log(`[openTab] Playwright function not available, falling through to CDP`);}console.log(`[openTab] Using standard CDP path`);if (requiresInspectableBrowserNavigationRedirects(state().resolved.ssrfPolicy)) {throw new InvalidBrowserNavigationUrlError("Navigation blocked: strict browser SSRF policy requires Playwright-backed redirect-hop inspection",);}const createdViaCdp = await createTargetViaCdp({cdpUrl: profile.cdpUrl,url,...ssrfPolicyOpts,timeoutMs: cdpNewTimeout, // 传递超时参数}).then((r) => r.targetId).catch(() => null);if (createdViaCdp) {const profileState = getProfileState();profileState.lastTargetId = createdViaCdp;const deadline = Date.now() + OPEN_TAB_DISCOVERY_WINDOW_MS;while (Date.now() < deadline) {const tabs = await listTabs().catch(() => [] as BrowserTab[]);const found = tabs.find((t) => t.targetId === createdViaCdp);if (found) {await assertBrowserNavigationResultAllowed({ url: found.url, ...ssrfPolicyOpts });triggerManagedTabLimit(found.targetId);return found;}await new Promise((r) => setTimeout(r, OPEN_TAB_DISCOVERY_POLL_MS));}triggerManagedTabLimit(createdViaCdp);return { targetId: createdViaCdp, title: "", url, type: "page" };}const encoded = encodeURIComponent(url);const endpointUrl = new URL(appendCdpPath(cdpHttpBase, "/json/new"));await assertBrowserNavigationAllowed({ url, ...ssrfPolicyOpts });const endpoint = endpointUrl.search? (() => {endpointUrl.searchParams.set("url", url);return endpointUrl.toString();})(): `${endpointUrl.toString()}?${encoded}`;console.log(`[openTab] Fetching /json/new: ${endpoint}, timeout: ${cdpNewTimeout}ms`);const created = await fetchJson<CdpTarget>(endpoint, cdpNewTimeout, {method: "PUT",}).catch(async (err) => {console.error(`[openTab] PUT failed:`, err);if (String(err).includes("HTTP 405")) {console.log(`[openTab] Retrying with GET`);return await fetchJson<CdpTarget>(endpoint, cdpNewTimeout);}throw err;});if (!created.id) {console.error(`[openTab] Missing id in response:`, created);throw new Error("Failed to open tab (missing id)");}console.log(`[openTab] ✅ Tab created: id=${created.id}`);const profileState = getProfileState();profileState.lastTargetId = created.id;triggerManagedTabLimit(created.id);return { targetId: created.id, title: created.title ?? "", url: created.url ?? url, type: created.type ?? "page" };};
「文档版本」:v1.0「最后更新」:2026-03-24「适用版本」:OpenClaw Gateway (Docker 环境)
参考PR: https://github.com/openclaw/openclaw/pull/49392
看到这里,点个关注再走呗!
夜雨聆风