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 实战

相关推荐
Ai拆代码的曹操11 小时前
Nginx upstream keepalive 连接池中毒排查实录
chrome·nginx·php
IT小白杨11 小时前
防关联用指纹浏览器还是虚拟机好?2026年技术架构深度对比与选型决策
chrome·经验分享·microsoft·架构·安全架构·指纹浏览器
不可能掉发2 天前
Env Guard:让浏览器一眼分清生产、测试和开发环境
前端·javascript·chrome·测试工具·html·开源软件·个人开发
小龙飞刀2 天前
Chrome插件自动化测试:单元测试、集成测试
chrome·单元测试·集成测试
东方护航数据恢复(深圳)4 天前
服务器硬盘黄灯/红灯故障排查与处理命令全指南_东方护航数据恢复深圳店
运维·服务器·chrome
赵广陆4 天前
FastAPI零基础完整实战
前端·chrome·fastapi
Nuanyt5 天前
Linux系统的 Shell 常见指令和常见知识点(以bash为主)
linux·运维·chrome·bash
lpfasd1237 天前
MediaCrawler 项目深度分析
chrome·python·chrome devtools
水瓶夜之7 天前
.NET 中的新增功能系列文章——.NET SDK中的新增功能
前端·chrome·.net
代码对我眨眼睛7 天前
macOS Google Chrome 常用快捷键
前端·chrome·macos