car-browser 0.52.1

Browser automation and perception pipeline for Common Agent Runtime
//! Who currently holds which persistent Chromium profile directory, within
//! this process.
//!
//! Chromium's `ProcessSingleton` allows exactly one live instance per
//! `--user-data-dir`. A second launch does not degrade — it dies at startup
//! with `Failed to create <dir>/SingletonLock: File exists (17) … Aborting
//! now to avoid profile corruption`, which surfaces to a user as a browser
//! that simply refuses to open.
//!
//! Two CAR browsers wanting persistence at once is an ordinary situation, not
//! an edge case: two agents browsing in two conversations, or an agent
//! browsing while the person uses the drawer's standing session. Distinct
//! purposes already get distinct directories (an agent's
//! `~/.car/browser-profile`, the standing session's
//! `~/.car/browser-profile-user`), which covers the common case exactly. This
//! module covers what is left: the SAME directory wanted twice.
//!
//! The answer is a claim registry. The first launch to ask for a persistent
//! directory holds it until its backend drops; a second launch asking for the
//! same directory is told so and falls back to the per-instance ephemeral
//! tempdir car-browser already defaults to. That browser works — it just
//! starts signed out, which is strictly better than not starting at all.
//!
//! **Deliberately in-process only.** A file lock would also catch a second
//! daemon, or a `car browse` CLI run in another terminal — but it would
//! introduce a lock whose staleness after a crash is its own failure mode,
//! and this registry's job is the same-process case. Cross-process collision
//! is caught reactively instead: Chromium reports `SingletonLock: File
//! exists` and `ChromiumBackend::launch_with_options` relaunches once against
//! a throwaway directory, so that browser starts (signed out) rather than not
//! at all. Two supervised `car do --serve` agents is the shape that actually
//! produces it.

use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};

fn claims() -> &'static Mutex<HashSet<PathBuf>> {
    static CLAIMS: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
    CLAIMS.get_or_init(|| Mutex::new(HashSet::new()))
}

/// A held persistent profile directory. Releases on drop, so a backend that
/// panics or is killed frees its directory for the next launch.
#[derive(Debug)]
pub struct ProfileClaim {
    dir: PathBuf,
}

impl ProfileClaim {
    /// Claim `dir` for this process, or `None` if a live backend already
    /// holds it.
    ///
    /// Not re-entrant on purpose: "already claimed" is exactly the question
    /// being asked, and a caller that wanted to share would be asking for the
    /// SingletonLock crash this exists to prevent.
    pub fn acquire(dir: &Path) -> Option<Self> {
        let dir = dir.to_path_buf();
        let mut held = claims().lock().unwrap_or_else(|e| e.into_inner());
        if !held.insert(dir.clone()) {
            return None;
        }
        Some(Self { dir })
    }

    pub fn dir(&self) -> &Path {
        &self.dir
    }
}

impl Drop for ProfileClaim {
    fn drop(&mut self) {
        claims()
            .lock()
            .unwrap_or_else(|e| e.into_inner())
            .remove(&self.dir);
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Each test uses a distinct directory: the registry is process-global by
    /// design, so tests sharing a path would contend with each other exactly
    /// the way two browsers would.
    fn dir(name: &str) -> PathBuf {
        std::env::temp_dir().join(format!("car-profile-claim-test-{name}"))
    }

    #[test]
    fn the_first_claim_on_a_directory_succeeds() {
        let path = dir("first");
        let claim = ProfileClaim::acquire(&path).expect("nothing holds it yet");
        assert_eq!(claim.dir(), path.as_path());
    }

    /// The whole point: the second asker is TOLD, rather than being allowed
    /// to launch a Chromium that dies on SingletonLock.
    #[test]
    fn a_second_claim_on_a_held_directory_is_refused() {
        let path = dir("second");
        let _held = ProfileClaim::acquire(&path).expect("first");
        assert!(
            ProfileClaim::acquire(&path).is_none(),
            "a live backend already holds this profile"
        );
    }

    #[test]
    fn releasing_a_claim_frees_the_directory_for_the_next_launch() {
        let path = dir("release");
        let claim = ProfileClaim::acquire(&path).expect("first");
        drop(claim);
        let again = ProfileClaim::acquire(&path);
        assert!(
            again.is_some(),
            "a dropped backend must not hold its profile forever"
        );
    }

    #[test]
    fn different_directories_do_not_contend() {
        let a = ProfileClaim::acquire(&dir("distinct-a")).expect("a");
        let b = ProfileClaim::acquire(&dir("distinct-b")).expect("b");
        assert_ne!(a.dir(), b.dir());
    }

    /// A poisoned registry mutex must not take every future browser launch
    /// down with it — the set is a plain collection of paths with no
    /// invariant a panic could have broken.
    #[test]
    fn a_poisoned_registry_still_serves_claims() {
        let path = dir("poison");
        let _ = std::panic::catch_unwind(|| {
            let _guard = claims().lock().unwrap();
            panic!("poison the registry");
        });
        assert!(ProfileClaim::acquire(&path).is_some());
    }
}