agent-browser 源码分析(二):WebSocket CDP 客户端

CDP WebSocket 客户端实现:命令/响应匹配与事件广播

本文是「agent-browser 代码原理」系列第 2 篇,深入剖析 agent-browser 的 CDP WebSocket 客户端实现。


一、CDP 通信模型

Chrome DevTools Protocol 基于 JSON-RPC over WebSocket,有三种消息类型:

json 复制代码
// 1. 命令(Client → Chrome)
{"id": 1, "method": "Runtime.evaluate", "params": {"expression": "document.title"}}

// 2. 响应(Chrome → Client)
{"id": 1, "result": {"value": "Example Domain"}}

// 3. 事件(Chrome → Client,无 id)
{"method": "Page.loadEventFired", "params": {"timestamp": 1234567890}}

关键挑战 :WebSocket 是全双工、异步、无序的。客户端发送命令 1、2、3 后,可能先收到事件 A,再收到响应 2,然后是事件 B,最后是响应 1。


二、CdpClient 设计

cdp/client.rs 仅 360 行 Rust 代码,却解决了一个生产级问题:如何在异步环境中可靠地匹配请求和响应

2.1 数据结构

rust 复制代码
pub struct CdpClient {
    // WebSocket 发送端(需要 Mutex,因为多个任务可能同时发送)
    ws_tx: Arc<Mutex<SplitSink<WebSocketStream, Message>>>,

    // 自增命令 ID(AtomicU64 保证线程安全)
    next_id: AtomicU64,

    // 等待中的命令:id → oneshot::Sender<CdpMessage>
    // 当收到对应 id 的响应时,通过 Sender 通知等待方
    pending: Arc<Mutex<HashMap<u64, oneshot::Sender<CdpMessage>>>>,

    // 事件广播:所有 CDP 事件通过 broadcast channel 分发给订阅者
    event_tx: broadcast::Sender<CdpEvent>,

    // 原始消息广播:用于 inspect proxy(DevTools 前端转发)
    raw_tx: broadcast::Sender<RawCdpMessage>,

    // Reader 任务和 Keepalive 任务的句柄
    _reader_handle: tokio::task::JoinHandle<()>,
    _keepalive_handle: tokio::task::JoinHandle<()>,
}

2.2 为什么用 oneshot channel?

oneshot::channel 是 Tokio 提供的一次性异步通道: - 发送端(Sender)只能发送一次 - 接收端(Receiver)只能接收一次 - 非常适合"请求-响应"模式------每个命令只期待一个响应

对比其他选择:

方案 问题
mpsc 多生产者多消费者,过于复杂
broadcast 适合事件,不适合一对一匹配
oneshot ✅ 完美匹配请求-响应语义

三、连接建立

3.1 WebSocket 握手

rust 复制代码
pub async fn connect(url: &str) -> Result<Self, String> {
    // 1. 将 URL 转换为 WebSocket 请求
    let mut request = url.into_client_request()
        .map_err(|e| format!("Invalid WebSocket URL: {}", e))?;

    // 2. 配置 WebSocket 参数
    let ws_config = WebSocketConfig {
        max_message_size: None,
        max_frame_size: None,
        ..Default::default()
    };

    // 3. 异步连接
    let (ws_stream, _) = tokio_tungstenite::connect_async_with_config(
        request, Some(ws_config), false
    ).await.map_err(|e| format!("CDP WebSocket connect failed: {}", e))?;

    // 4. 启用 TCP Keepalive
    enable_tcp_keepalive(ws_stream.get_ref());

    // 5. 分离发送端和接收端
    let (ws_tx, mut ws_rx) = ws_stream.split();
    let ws_tx = Arc::new(Mutex::new(ws_tx));

    // ... 初始化 pending、event_tx、raw_tx

    // 6. 启动 Reader 任务
    let reader_handle = tokio::spawn(async move {
        // 持续读取消息...
    });

    // 7. 启动 Keepalive 任务
    let keepalive_handle = tokio::spawn(async move {
        // 每 30 秒发送 Ping...
    });

    Ok(Self { ws_tx, next_id, pending, event_tx, raw_tx, 
              _reader_handle: reader_handle, _keepalive_handle: keepalive_handle })
}

3.2 支持自定义 Headers

某些 CDP 代理(如 Browserless)需要认证 Header:

rust 复制代码
pub async fn connect_with_headers(
    url: &str,
    headers: Option<Vec<(String, String)>>,
) -> Result<Self, String> {
    let mut request = url.into_client_request()?;

    if let Some(hdrs) = headers {
        let req_headers = request.headers_mut();
        for (key, value) in hdrs {
            if let (Ok(name), Ok(val)) = (
                key.parse::<HeaderName>(),
                value.parse::<HeaderValue>(),
            ) {
                req_headers.insert(name, val);
            }
        }
    }

    // ... 后续与 connect 相同
}

四、命令发送与响应匹配

4.1 发送流程

rust 复制代码
pub async fn send_command(
    &self,
    method: &str,
    params: Option<Value>,
    session_id: Option<&str>,
) -> Result<Value, String> {
    // Step 1: 生成唯一 ID
    let id = self.next_id.fetch_add(1, Ordering::SeqCst);

    // Step 2: 序列化命令
    let cmd = CdpCommand {
        id,
        method: method.to_string(),
        params,
        session_id: session_id.filter(|s| !s.is_empty()).map(|s| s.to_string()),
    };
    let json = serde_json::to_string(&cmd)
        .map_err(|e| format!("Failed to serialize CDP command: {}", e))?;

    // Step 3: 创建 oneshot channel,将 Sender 存入 pending
    let (tx, rx) = oneshot::channel();
    {
        let mut pending = self.pending.lock().await;
        pending.insert(id, tx);
    }

    // Step 4: 发送 WebSocket 消息
    {
        let mut ws_tx = self.ws_tx.lock().await;
        ws_tx.send(Message::Text(json)).await
            .map_err(|e| format!("Failed to send CDP command: {}", e))?;
    }

    // Step 5: 等待响应(30 秒超时)
    let response = match tokio::time::timeout(
        std::time::Duration::from_secs(30), rx
    ).await {
        Ok(Ok(resp)) => resp,
        Ok(Err(_)) => return Err("CDP response channel closed".to_string()),
        Err(_) => {
            // 超时:从 pending 中移除,防止内存泄漏
            self.pending.lock().await.remove(&id);
            return Err(format!("CDP command timed out: {}", method));
        }
    };

    // Step 6: 处理错误响应
    if let Some(error) = response.error {
        return Err(format!("CDP error ({}): {}", method, error));
    }

    Ok(response.result.unwrap_or(Value::Null))
}

关键点 : 1. pending.insert(id, tx) 必须在发送消息之前 完成,防止响应在插入前到达 2. 超时后必须 remove(&id),否则 pending HashMap 会无限增长 3. Ordering::SeqCst 保证 ID 生成的原子性和顺序性

4.2 类型安全的命令封装

agent-browser 还提供了类型安全版本:

rust 复制代码
pub async fn send_command_typed<P: serde::Serialize, R: serde::de::DeserializeOwned>(
    &self,
    method: &str,
    params: &P,
    session_id: Option<&str>,
) -> Result<R, String> {
    let params_value = serde_json::to_value(params)
        .map_err(|e| format!("Failed to serialize params: {}", e))?;
    let result = self.send_command(method, Some(params_value), session_id).await?;
    serde_json::from_value(result)
        .map_err(|e| format!("Failed to deserialize CDP response for {}: {}", method, e))
}

使用示例:

rust 复制代码
let result: EvaluateResult = client
    .send_command_typed(
        "Runtime.evaluate",
        &EvaluateParams {
            expression: "document.title".to_string(),
            return_by_value: Some(true),
            await_promise: Some(false),
        },
        Some(session_id),
    ).await?;

五、Reader 任务:消息解析与分发

Reader 任务是 CdpClient 的核心,负责持续读取 WebSocket 消息并分发给正确的消费者。

5.1 完整 Reader 逻辑

rust 复制代码
let reader_handle = tokio::spawn(async move {
    while let Some(msg) = ws_rx.next().await {
        // 处理不同类型的 WebSocket 消息
        let text = match msg {
            Ok(Message::Text(text)) => text,
            Ok(Message::Binary(data)) => match String::from_utf8(data) {
                Ok(text) => text,
                Err(_) => continue, // 二进制数据非法 UTF-8,跳过
            },
            Ok(Message::Close(frame)) => {
                // 连接关闭,退出循环
                if std::env::var("AGENT_BROWSER_DEBUG").is_ok() {
                    let reason = frame.as_ref()
                        .map(|f| format!("code={}, reason={}", f.code, f.reason))
                        .unwrap_or_else(|| "no frame".to_string());
                    let _ = writeln!(std::io::stderr(), "[cdp] WebSocket Close: {}", reason);
                }
                break;
            }
            Ok(Message::Pong(_)) => continue, // Pong 响应,忽略
            Ok(_) => continue, // 其他类型(如 Ping),忽略
            Err(e) => {
                if std::env::var("AGENT_BROWSER_DEBUG").is_ok() {
                    let _ = writeln!(std::io::stderr(), "[cdp] WebSocket Error: {}", e);
                }
                break;
            }
        };

        // 广播原始消息(供 inspect proxy 使用)
        if raw_tx_clone.receiver_count() > 0 {
            let session_id = serde_json::from_str::<Value>(&text)
                .ok()
                .and_then(|v| v.get("sessionId")?.as_str().map(String::from));
            let _ = raw_tx_clone.send(RawCdpMessage {
                text: text.clone(),
                session_id,
            });
        }

        // 解析为结构化消息
        let parsed: CdpMessage = match serde_json::from_str(&text) {
            Ok(m) => m,
            Err(_) => continue, // 非法 JSON(如 inspect proxy 的负 ID 消息)
        };

        if let Some(id) = parsed.id {
            // ===== 响应消息 =====
            let mut pending = pending_clone.lock().await;
            if let Some(tx) = pending.remove(&id) {
                let _ = tx.send(parsed); // 通知等待方
            }
        } else if let Some(ref method) = parsed.method {
            // ===== 事件消息 =====
            let event = CdpEvent {
                method: method.clone(),
                params: parsed.params.clone().unwrap_or(Value::Null),
                session_id: parsed.session_id.clone(),
            };
            let _ = event_tx_clone.send(event); // 广播给所有订阅者
        }
    }

    // ===== 连接断开处理 =====
    // 清空所有 pending,让等待方立即收到 channel-closed 错误
    pending_clone.lock().await.clear();
    // 通知 keepalive 任务停止
    let _ = cancel_tx.send(true);
});

5.2 为什么要支持 Binary 帧?

CDP 标准使用 Text 帧,但某些远程 CDP 代理(如 Browserless)可能发送 Binary 帧:

rust 复制代码
Ok(Message::Binary(data)) => match String::from_utf8(data) {
    Ok(text) => text,
    Err(_) => continue,
},

这是防御性编程------兼容非标准实现。


六、事件订阅机制

6.1 订阅事件

rust 复制代码
pub fn subscribe(&self) -> broadcast::Receiver<CdpEvent> {
    self.event_tx.subscribe()
}

使用示例(等待页面加载完成):

rust 复制代码
let mut events = client.subscribe();

// 发送导航命令
client.send_command("Page.navigate", Some(json!({"url": "https://example.com"})), None).await?;

// 等待 Page.loadEventFired 事件
while let Ok(event) = events.recv().await {
    if event.method == "Page.loadEventFired" {
        break;
    }
}

6.2 多订阅者支持

broadcast::channel(4096) 支持任意数量的订阅者: - snapshot 模块订阅 Page.screencastFrame 事件 - network 模块订阅 Network.requestWillBeSentNetwork.responseReceived 事件 - 用户代码也可以同时订阅事件


七、Keepalive 机制

7.1 为什么需要 Keepalive?

在以下场景中,空闲的 WebSocket 连接可能被中间层关闭: - nginx 默认 60 秒超时 - Envoy / OpenResty / 云负载均衡器 - 企业防火墙 / 代理服务器

7.2 双保险设计

agent-browser 实现了两层保活机制:

Layer 1: TCP Keepalive(操作系统层)

rust 复制代码
fn enable_tcp_keepalive(stream: &MaybeTlsStream<TcpStream>) {
    let tcp_stream = match stream {
        MaybeTlsStream::Plain(s) => s,
        MaybeTlsStream::Rustls(s) => s.get_ref().0,
        _ => return,
    };

    let sock = socket2::SockRef::from(tcp_stream);
    let keepalive = TcpKeepalive::new()
        .with_time(Duration::from_secs(30)); // 30 秒后开始探测

    #[cfg(not(any(target_os = "openbsd", target_os = "haiku")))]
    let keepalive = keepalive.with_interval(Duration::from_secs(10)); // 每 10 秒探测一次

    let _ = sock.set_tcp_keepalive(&keepalive); // 失败静默处理
}

Layer 2: WebSocket Ping 帧(应用层)

rust 复制代码
const WS_KEEPALIVE_INTERVAL_SECS: u64 = 30;

let keepalive_handle = tokio::spawn(async move {
    let interval = Duration::from_secs(WS_KEEPALIVE_INTERVAL_SECS);
    loop {
        tokio::select! {
            _ = tokio::time::sleep(interval) => {}
            _ = cancel_rx.changed() => break, // Reader 退出时停止
        }
        let mut tx = keepalive_tx.lock().await;
        if tx.send(Message::Ping(Vec::new())).await.is_err() {
            break; // 发送失败,连接已断开
        }
    }
});

八、Inspect Proxy

agent-browser 还内置了一个 DevTools Inspect 代理服务器,允许用户使用 Chrome DevTools 前端调试正在自动化的页面。

8.1 InspectProxyHandle

rust 复制代码
pub struct InspectProxyHandle {
    ws_tx: WsTx,                    // 向 Chrome 发送消息
    raw_tx: broadcast::Sender<RawCdpMessage>, // 接收 Chrome 的消息
}

8.2 消息转发

DevTools 前端 ←→ Inspect Server ←→ CdpClient ←→ Chrome

  • DevTools 发送的消息 → 通过 send_raw 转发给 Chrome
  • Chrome 返回的消息 → 通过 subscribe_raw 转发给 DevTools

九、总结

agent-browser 的 CDP 客户端设计精妙之处在于:

设计点 实现 价值
请求-响应匹配 HashMap<id, oneshot::Sender> 可靠匹配异步响应
事件广播 broadcast::channel(4096) 支持多模块并发订阅
双保活机制 TCP Keepalive + WebSocket Ping 穿透各类中间代理
Binary 帧兼容 String::from_utf8(data) 兼容非标准 CDP 代理
连接断开处理 pending.clear() + cancel_tx 优雅退出,无资源泄漏

这些设计使得 agent-browser 的 CDP 客户端不仅功能完整,而且在生产环境中非常可靠。


系列文章 : 1. agent-browser 架构概览:从 CLI 到 CDP 的分层设计 2. CDP WebSocket 客户端实现:命令/响应匹配与事件广播 ← 本文 3. Accessibility Tree 快照原理:如何让 AI 看懂网页 4. Chrome 进程管理与多 Backend 架构 5. Network 拦截与路由:Fetch Domain 实战

相关推荐
x-cmd2 小时前
agent-browser 与 CDP:浏览器自动化的底层原理
rust·浏览器自动化·cdp·agent-browser·chrome devtools protocol
时空系1 天前
认识Rust——我的第一个程序 Rust中文编程
开发语言·后端·rust
时空系1 天前
第10篇:归属权与借用——Rust的安全保障 Rust中文编程
开发语言·安全·rust
时空系1 天前
第6篇:数据容器——管理大量数据 Rust中文编程
开发语言·后端·rust
ai_coder_ai1 天前
在自动化脚本中如何使用websocket?
websocket·autojs·自动化脚本·冰狐智能辅助·easyclick
时空系1 天前
第7篇:功能——打造你的工具箱 Rust中文编程
开发语言·网络·rust
qcx231 天前
拆解 Warp AI Agent(五):跨生态联邦——10 种 Skill + MCP + 多 Harness 互操作设计
人工智能·rust·ai agent·skill·warp·mcp·harness