Skip to main content

browser_commander/browser/parity/
reference.rs

1//! Plain command-stream reference process with deterministic/cancellation cleanup.
2
3use std::path::{Path, PathBuf};
4use std::sync::{Arc, Mutex};
5use std::time::Duration;
6
7use anyhow::{bail, Result};
8use serde_json::Value;
9
10use super::{server::ProbeServer, MeasureParityOptions};
11use crate::browser::profile_directory::{create_temporary_user_data_dir, remove_user_data_dir};
12use crate::utilities::{start_process, ManagedProcess, StartProcessOptions};
13
14struct Reference {
15    directory: PathBuf,
16    child: Option<ManagedProcess>,
17}
18
19/// Arguments for a plain reference browser, with no implicit automation or CDP switches.
20pub fn build_reference_args(
21    directory: &Path,
22    url: &str,
23    headless: bool,
24    extra_args: &[String],
25) -> Vec<String> {
26    let mut args = vec![format!("--user-data-dir={}", directory.display())];
27    if headless {
28        args.push("--headless=new".into());
29    }
30    args.extend_from_slice(extra_args);
31    args.push(url.into());
32    args
33}
34
35impl Reference {
36    async fn close(&mut self) -> Result<()> {
37        if let Some(child) = self.child.take() {
38            child.kill();
39            child.wait_timeout(Duration::from_secs(6)).await;
40        }
41        remove_user_data_dir(&self.directory).await
42    }
43}
44
45impl Drop for Reference {
46    fn drop(&mut self) {
47        let directory = self.directory.clone();
48        if let Some(child) = self.child.take() {
49            child.kill();
50            if let Ok(runtime) = tokio::runtime::Handle::try_current() {
51                runtime.spawn(async move {
52                    child.wait_timeout(Duration::from_secs(6)).await;
53                    let _ = remove_user_data_dir(&directory).await;
54                });
55                return;
56            }
57        }
58        let _ = std::fs::remove_dir_all(directory);
59    }
60}
61
62pub(super) async fn capture(
63    server: &ProbeServer,
64    executable: &Path,
65    options: &MeasureParityOptions,
66) -> Result<Value> {
67    let mut reference = Reference {
68        directory: create_temporary_user_data_dir(None)?,
69        child: None,
70    };
71    let args = build_reference_args(
72        &reference.directory,
73        &server.reference_url,
74        options.launch.headless,
75        &options.reference_args,
76    );
77    let stderr = Arc::new(Mutex::new(Vec::<u8>::new()));
78    let tail = stderr.clone();
79    reference.child = Some(
80        start_process(
81            &executable.to_string_lossy(),
82            &args,
83            StartProcessOptions {
84                kill_grace: Duration::from_secs(3),
85                on_stderr: vec![Arc::new(move |chunk| {
86                    if let Ok(mut tail) = tail.lock() {
87                        tail.extend_from_slice(chunk);
88                        let remove = tail.len().saturating_sub(4000);
89                        tail.drain(..remove);
90                    }
91                })],
92                ..Default::default()
93            },
94        )
95        .await?,
96    );
97    let child = reference.child.as_ref().unwrap();
98    let exited = async {
99        let code = child.wait().await;
100        if code == 0 {
101            std::future::pending::<()>().await;
102        }
103        bail!(
104            "reference browser {} exited with code {code} before reporting",
105            executable.display()
106        );
107    };
108    let captured = tokio::select! {
109        report = server.report(true, options.timeout) => report,
110        result = exited => result,
111    };
112    let captured = captured.map_err(|error| {
113        let tail = stderr
114            .lock()
115            .map(|bytes| String::from_utf8_lossy(&bytes).into_owned())
116            .unwrap_or_default();
117        anyhow::anyhow!("{error}\nbrowser stderr:\n{tail}")
118    });
119    let closed = reference.close().await;
120    let report = captured?;
121    closed?;
122    Ok(report)
123}
124
125#[cfg(all(test, unix))]
126mod tests {
127    use super::*;
128
129    #[tokio::test]
130    async fn reports_early_reference_exit_with_stderr() -> Result<()> {
131        let server = ProbeServer::start().await?;
132        // sh rejects the browser's arguments immediately: a bounded startup failure.
133        let error = tokio::time::timeout(
134            Duration::from_secs(3),
135            capture(
136                &server,
137                Path::new("/bin/sh"),
138                &MeasureParityOptions::default(),
139            ),
140        )
141        .await?
142        .unwrap_err();
143        let text = error.to_string();
144        assert!(text.contains("exited with code"), "{text}");
145        assert!(text.contains("browser stderr:"), "{text}");
146        Ok(())
147    }
148
149    #[tokio::test]
150    async fn cancellation_reaps_reference_before_removing_its_profile() -> Result<()> {
151        let directory = create_temporary_user_data_dir(None)?;
152        let path = directory.clone();
153        let (ready, started) = tokio::sync::oneshot::channel();
154        let task = tokio::spawn(async move {
155            let reference = Reference {
156                directory,
157                child: Some(
158                    start_process(
159                        "/bin/sh",
160                        &["-c", "exec sleep 30"],
161                        StartProcessOptions {
162                            kill_grace: Duration::from_millis(50),
163                            ..Default::default()
164                        },
165                    )
166                    .await
167                    .unwrap(),
168                ),
169            };
170            ready
171                .send(reference.child.as_ref().unwrap().pid().unwrap())
172                .unwrap();
173            std::future::pending::<()>().await;
174            drop(reference);
175        });
176        let pid = started.await?;
177        task.abort();
178        let _ = task.await;
179        tokio::time::timeout(Duration::from_secs(7), async {
180            while path.exists() {
181                tokio::time::sleep(Duration::from_millis(20)).await;
182            }
183        })
184        .await?;
185        let result = crate::utilities::run_command(
186            "/bin/kill",
187            &["-0".to_owned(), pid.to_string()],
188            crate::utilities::RunCommandOptions {
189                check: false,
190                ..Default::default()
191            },
192        )
193        .await?;
194        assert_ne!(
195            result.code, 0,
196            "reference process {pid} survived cancellation"
197        );
198        Ok(())
199    }
200}