agent-browser 源码分析(四):Chrome 进程管理与多 Backend

Chrome 进程管理与多 Backend 架构

本文是「agent-browser 代码原理」系列第 4 篇,深入剖析 agent-browser 的 Chrome 进程管理和多 Backend 抽象。


一、Chrome 进程模型

Chrome 是多进程架构浏览器,启动后会创建多个进程:

进程类型 作用
Browser Process 主进程,管理 UI、标签页、网络请求
GPU Process 图形渲染
Renderer Process 每个标签页一个,执行 JS、渲染页面
Utility Process 音频、视频解码等
Crashpad Handler 崩溃报告

自动化工具的噩梦:如果 Browser Process 被 kill 但 Renderer Process 还在运行,这些孤儿进程会占用资源,甚至阻塞正常 Chrome 的启动。


二、ChromeProcess 结构

cdp/chrome.rs 中的 ChromeProcess 结构体负责管理 Chrome 的完整生命周期:

rust 复制代码
pub struct ChromeProcess {
    child: Child,                    // Rust std::process::Child
    pub ws_url: String,              // WebSocket 调试 URL
    temp_user_data_dir: Option<PathBuf>, // 临时用户数据目录
    #[cfg(unix)]
    pgid: Option<i32>,               // Unix 进程组 ID
}

2.1 Drop trait:自动清理

rust 复制代码
impl Drop for ChromeProcess {
    fn drop(&mut self) {
        // 1. 杀掉 Chrome 进程
        self.kill();

        // 2. 清理临时用户数据目录
        if let Some(ref dir) = self.temp_user_data_dir {
            for attempt in 0..3 {
                match std::fs::remove_dir_all(dir) {
                    Ok(()) => break,
                    Err(_) if attempt < 2 => {
                        std::thread::sleep(Duration::from_millis(100));
                    }
                    Err(e) => {
                        let _ = writeln!(
                            std::io::stderr(),
                            "Warning: failed to clean up temp profile {}: {}",
                            dir.display(), e
                        );
                    }
                }
            }
        }
    }
}

为什么重试 3 次? Chrome 退出时可能还在写入文件,立即删除会失败。等待 100ms 后重试提高成功率。


三、进程组 kill:解决孤儿进程问题

3.1 问题的根源

在 Unix 系统上,child.kill() 只发送信号给父进程(Browser Process),但 Renderer、GPU 等子进程属于同一个进程组,需要一起杀掉。

3.2 进程组 kill 实现

rust 复制代码
impl ChromeProcess {
    pub fn kill(&mut self) {
        // 第一步:尝试正常 kill
        let _ = self.child.kill();

        // 第二步:Unix 系统杀掉整个进程组
        #[cfg(unix)]
        if let Some(pgid) = self.pgid {
            unsafe {
                // kill(-pgid, SIGKILL) 发送给进程组中所有进程
                libc::kill(-pgid, libc::SIGKILL);
            }
        }

        // 第三步:等待子进程退出(reap zombie)
        let _ = self.child.wait();
    }
}

关键点 : - kill(-pgid, SIGKILL) 中的负号表示"进程组" - SIGKILL(信号 9)无法被捕获或忽略,强制终止 - 这是解决 issue #1113(孤儿 Chrome 进程阻塞正常 Chrome)的方案

3.3 启动时设置进程组

rust 复制代码
#[cfg(unix)]
fn setup_process_group(cmd: &mut Command) {
    unsafe {
        cmd.pre_exec(|| {
            // 设置子进程为新的进程组 leader
            libc::setpgid(0, 0);
            Ok(())
        });
    }
}

四、Chrome 发现与启动

4.1 查找 Chrome 可执行文件

agent-browser 会自动检测系统中已有的 Chrome:

复制代码
检测顺序:
1. 用户指定的 executable_path
2. 环境变量 CHROME_PATH
3. Google Chrome(macOS: /Applications/Google Chrome.app)
4. Microsoft Edge
5. Brave Browser
6. Playwright 安装的 Chromium
7. Puppeteer 安装的 Chromium
8. Chrome for Testing(agent-browser install 下载的)

4.2 启动参数构建

cdp/chrome.rsbuild_chrome_args 函数构建启动参数:

rust 复制代码
fn build_chrome_args(opts: &LaunchOptions) -> Result<ChromeArgs, String> {
    let mut args = vec![
        "--remote-debugging-port=0",     // 随机分配调试端口
        "--no-first-run",                 // 跳过首次运行向导
        "--no-default-browser-check",     // 不检查默认浏览器
        "--password-store=basic",         // 使用内存密码存储
        "--use-mock-keychain",            // 使用 mock keychain(macOS)
        "--disable-background-timer-throttling",
        "--disable-backgrounding-occluded-windows",
        "--disable-renderer-backgrounding",
    ];

    // 根据选项添加参数
    if opts.headless {
        args.push("--headless=new");      // 新版 headless 模式
    }

    if let Some(ref proxy) = opts.proxy {
        args.push(format!("--proxy-server={}", proxy));
    }

    if let Some((w, h)) = opts.viewport_size {
        args.push(format!("--window-size={}, {}", w, h));
    }

    // user-data-dir
    if let Some(ref profile) = opts.profile {
        args.push(format!("--user-data-dir={}", profile));
    } else {
        // 创建临时目录
        let temp_dir = tempfile::tempdir()?.into_path();
        args.push(format!("--user-data-dir={}", temp_dir.display()));
    }

    // 用户自定义参数
    args.extend(opts.args.iter().cloned());

    Ok(ChromeArgs { args, temp_user_data_dir: ... })
}

4.3 关键启动参数解析

参数 作用
--remote-debugging-port=0 随机端口,避免冲突
--headless=new 新版 headless,支持扩展和 DevTools
--password-store=basic 避免弹窗请求钥匙串访问(macOS)
--no-sandbox 容器环境必需(Docker、CI)
--disable-dev-shm-usage 避免 /dev/shm 空间不足(Docker)
--disable-gpu 无 GPU 环境必需

五、CDP URL 发现

Chrome 启动后,agent-browser 需要找到其 WebSocket 调试 URL。

5.1 DevToolsActivePort 文件

Chrome 启动后会在用户数据目录创建 DevToolsActivePort 文件:

复制代码
<port>\n<ws_path>\n

例如:

复制代码
51234
/devtools/browser/3f2c1e5a-7b8d-4c9f-a1b2-c3d4e5f6g7h8

5.2 发现流程

rust 复制代码
pub async fn discover_cdp_url(user_data_dir: &Path) -> Result<String, String> {
    let port_file = user_data_dir.join("DevToolsActivePort");

    // 轮询等待文件出现(最多 30 秒)
    for attempt in 0..300 {
        if port_file.exists() {
            let content = std::fs::read_to_string(&port_file)?;
            let mut lines = content.lines();
            let port = lines.next().ok_or("Missing port")?;
            let ws_path = lines.next().unwrap_or("/json/list");

            // 尝试直接连接 ws_path
            if let Ok(url) = resolve_cdp_from_active_port(port.parse()?, ws_path).await {
                return Ok(url);
            }

            // 回退到 HTTP 发现
            return discover_cdp_url_http(port.parse()?).await;
        }
        tokio::time::sleep(Duration::from_millis(100)).await;
    }

    Err("Timeout waiting for DevToolsActivePort".to_string())
}

5.3 HTTP 发现回退

如果 DevToolsActivePort 文件中没有 ws_path,回退到 HTTP 发现:

rust 复制代码
async fn discover_cdp_url_http(port: u16) -> Result<String, String> {
    let url = format!("http://127.0.0.1:{}/json/version", port);
    let resp = reqwest::get(&url).await?;
    let json: Value = resp.json().await?;

    json.get("webSocketDebuggerUrl")
        .and_then(|v| v.as_str())
        .map(String::from)
        .ok_or("Missing webSocketDebuggerUrl".to_string())
}

六、多 Backend 架构

agent-browser 的核心设计哲学是**"协议无关的浏览器操作"**------上层代码不关心底层是 CDP、WebDriver 还是 Appium。

6.1 Backend Trait

rust 复制代码
#[async_trait]
pub trait BrowserBackend: Send + Sync {
    async fn navigate(&self, url: &str) -> Result<(), String>;
    async fn get_url(&self) -> Result<String, String>;
    async fn get_title(&self) -> Result<String, String>;
    async fn evaluate(&self, script: &str) -> Result<Value, String>;
    async fn screenshot(&self, options: &ScreenshotOptions) -> Result<Vec<u8>, String>;
    // ... 其他通用操作
}

6.2 Backend 枚举

rust 复制代码
pub enum BrowserBackendType {
    Cdp(CdpBackend),
    WebDriver(WebDriverBackend),
    Appium(AppiumManager),
}

impl BrowserBackendType {
    pub async fn navigate(&self, url: &str) -> Result<(), String> {
        match self {
            Self::Cdp(b) => b.navigate(url).await,
            Self::WebDriver(b) => b.navigate(url).await,
            Self::Appium(b) => b.navigate(url).await,
        }
    }
    // ... 其他方法委托
}

6.3 CDP Backend

功能最全的 backend:

rust 复制代码
pub struct CdpBackend {
    client: CdpClient,
    browser: BrowserManager,
}

impl BrowserBackend for CdpBackend {
    async fn navigate(&self, url: &str) -> Result<(), String> {
        let session_id = self.browser.active_session_id()?;
        let params = json!({ "url": url });
        self.client.send_command("Page.navigate", Some(params), Some(session_id)).await?;
        Ok(())
    }

    async fn evaluate(&self, script: &str) -> Result<Value, String> {
        let session_id = self.browser.active_session_id()?;
        let params = EvaluateParams {
            expression: script.to_string(),
            return_by_value: Some(true),
            await_promise: Some(false),
        };
        let result: EvaluateResult = self.client
            .send_command_typed("Runtime.evaluate", &params, Some(session_id))
            .await?;
        Ok(json!(result.result.value))
    }
}

6.4 WebDriver Backend

通过 HTTP REST API 与 Selenium Grid / BrowserStack 通信:

rust 复制代码
pub struct WebDriverBackend {
    client: reqwest::Client,
    session_url: String,
}

impl BrowserBackend for WebDriverBackend {
    async fn navigate(&self, url: &str) -> Result<(), String> {
        let payload = json!({ "url": url });
        self.client.post(format!("{}/url", self.session_url))
            .json(&payload)
            .send().await?;
        Ok(())
    }

    async fn evaluate(&self, script: &str) -> Result<Value, String> {
        let payload = json!({ "script": script, "args": [] });
        let resp = self.client.post(format!("{}/execute/sync", self.session_url))
            .json(&payload)
            .send().await?;
        let json: Value = resp.json().await?;
        Ok(json.get("value").cloned().unwrap_or(Value::Null))
    }
}

6.5 功能对比

功能 CDP WebDriver Appium
snapshot
network 拦截
录屏
基本导航
元素点击
表单填写
截图
移动端

七、Target / Session 管理

CDP 中,每个标签页、iframe、Service Worker 都是一个 Target。agent-browser 需要管理这些 Target 的 Session。

7.1 Target 生命周期

复制代码
Chrome 启动
  → Target.createTarget (创建新标签页)
  → Target.attachToTarget (attach 获取 sessionId)
  → 通过 sessionId 发送命令
  → Target.detachFromTarget (detach)
  → Target.closeTarget (关闭)

7.2 Session 切换

rust 复制代码
impl BrowserManager {
    pub async fn switch_to_target(&mut self, target_id: &str) -> Result<(), String> {
        let result: AttachToTargetResult = self.client
            .send_command_typed(
                "Target.attachToTarget",
                &AttachToTargetParams {
                    target_id: target_id.to_string(),
                    flatten: Some(true),
                },
                Some(&self.browser_session_id),
            ).await?;

        self.active_session_id = Some(result.session_id);
        Ok(())
    }
}

八、用户数据目录管理

8.1 临时 Profile

默认情况下,agent-browser 创建临时用户数据目录,退出时自动清理:

rust 复制代码
let temp_dir = tempfile::tempdir()?;
// Chrome 使用这个目录
// Drop 时自动删除

8.2 复用现有 Profile

用户可以指定现有 Chrome Profile,保留登录态:

bash 复制代码
agent-browser open example.com --profile "~/Library/Application Support/Google/Chrome"

注意:复用 Profile 时,Chrome 可能无法启动(因为已有实例在运行)。agent-browser 会: 1. 复制 Profile 到临时目录 2. 在副本上启动 Chrome 3. 退出时将修改同步回原始 Profile


九、总结

agent-browser 的 Chrome 进程管理和多 Backend 架构体现了工程可靠性协议抽象的设计思想:

设计点 实现 价值
进程组 kill libc::kill(-pgid, SIGKILL) 彻底清理孤儿进程
Drop trait 自动 kill + 清理临时目录 防止资源泄漏
多 Backend Trait BrowserBackend async trait 协议无关的上层代码
CDP URL 发现 DevToolsActivePort + HTTP 回退 可靠连接
Target Session attachToTarget / detachFromTarget 多标签页管理

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

相关推荐
Das15 小时前
通过命令行下载kaggle数据
前端·chrome
x-cmd5 小时前
agent-browser 源码分析(二):WebSocket CDP 客户端
websocket·rust·cdp·json-rpc·agent-browser
x-cmd5 小时前
agent-browser 与 CDP:浏览器自动化的底层原理
rust·浏览器自动化·cdp·agent-browser·chrome devtools protocol
U盘失踪了1 天前
python curl转python脚本
开发语言·chrome·python
晓晨的博客1 天前
ROS1录制的bag包转换为ROS2格式
前端·chrome
love530love1 天前
如何在 Google Chrome 中强制开启 Gemini AI 侧边栏(完整图文教程)
前端·人工智能·chrome·windows
一乐小哥2 天前
坚持迭代一个 Chrome 插件半年后,我的同事问我:"这不是 Chrome 自带的功能吗?"
chrome·github·ai编程
架构源启2 天前
OpenClaw 只能手动写脚本?我用 Chrome 插件实现了“录制即生成“
前端·人工智能·chrome·自动化
irpywp2 天前
苦于AI生成的网页千篇一律且粗糙?design-md-chrome :一款网页样式提取插件 ,将任意网站的视觉规范转化为大模型可读的代码指令!
前端·人工智能·chrome·开源·github