Skip to main content

cbf_chrome/
process.rs

1//! Chromium process launch and startup wiring for `cbf-chrome`.
2//!
3//! This module resolves the runtime executable, prepares bridge startup state,
4//! launches the Chromium child process, and connects it to
5//! [`crate::backend::ChromiumBackend`]. It owns process-level concerns such as
6//! runtime selection, command-line startup options, and initial IPC session
7//! establishment.
8
9use async_process::{Child, Command};
10use cbf::{
11    browser::{BrowserHandle, BrowserSession, EventStream},
12    delegate::BackendDelegate,
13    error::Error,
14};
15use cbf_chrome_sys::{
16    bridge::{BridgeLoadError, bridge},
17    ffi::CbfBridgeClientHandle,
18};
19use futures_lite::future::block_on;
20use signal_hook::iterator::Signals;
21use std::{
22    collections::BTreeSet,
23    env,
24    path::PathBuf,
25    process::ExitStatus,
26    sync::{
27        Arc,
28        atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering},
29    },
30    thread,
31    time::{Duration, Instant},
32};
33
34use crate::{
35    backend::{ChromiumBackend, ChromiumBackendOptions},
36    bridge::{BridgeError, IpcClient},
37    data::custom_scheme::ChromeCustomSchemeRegistration,
38};
39
40/// Resolves Chromium executable path for CBF applications.
41///
42/// Resolution order:
43/// 1. Explicit path (for example CLI argument)
44/// 2. `CBF_CHROMIUM_EXECUTABLE` environment variable
45/// 3. Path relative to current app bundle:
46///    `../CBF Runtime/<RuntimeName>.app/Contents/MacOS/<RuntimeName>`
47///
48/// Returns `None` when no candidate can be resolved.
49pub fn resolve_chromium_executable(explicit_path: Option<PathBuf>) -> Option<PathBuf> {
50    explicit_path
51        .or_else(|| env::var_os("CBF_CHROMIUM_EXECUTABLE").map(PathBuf::from))
52        .or_else(resolve_chromium_executable_from_bundle)
53}
54
55fn resolve_chromium_executable_from_bundle() -> Option<PathBuf> {
56    let current_exe = env::current_exe().ok()?;
57    let macos_dir = current_exe.parent()?;
58    let contents_dir = macos_dir.parent()?;
59
60    if contents_dir.file_name()?.to_str()? != "Contents" {
61        return None;
62    }
63
64    resolve_chromium_executable_from_runtime_dir(contents_dir.join("CBF Runtime"))
65}
66
67fn resolve_chromium_executable_from_runtime_dir(runtime_dir: PathBuf) -> Option<PathBuf> {
68    let mut candidates = std::fs::read_dir(runtime_dir)
69        .ok()?
70        .filter_map(|entry| entry.ok())
71        .map(|entry| entry.path())
72        .filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("app"))
73        .filter_map(|app_path| {
74            let app_name = app_path.file_stem()?.to_str()?.to_owned();
75            let executable_path = app_path.join("Contents").join("MacOS").join(app_name);
76            executable_path.is_file().then_some(executable_path)
77        });
78
79    let first = candidates.next()?;
80    if candidates.next().is_some() {
81        return None;
82    }
83
84    Some(first)
85}
86
87/// Runtime selection for Chromium-backed startup.
88///
89/// `Chrome` is the only currently supported runtime. `Alloy` is reserved as an
90/// explicit future selection target, but remains unavailable in this phase.
91#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
92pub enum RuntimeSelection {
93    /// Chrome-backed runtime path used by current CBF integration.
94    #[default]
95    Chrome,
96    /// Reserved for future Alloy runtime work. Selecting this currently fails.
97    Alloy,
98}
99
100impl RuntimeSelection {
101    /// Stable string form for config surfaces and diagnostics.
102    pub const fn as_str(self) -> &'static str {
103        match self {
104            Self::Chrome => "chrome",
105            Self::Alloy => "alloy",
106        }
107    }
108}
109
110impl std::fmt::Display for RuntimeSelection {
111    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
112        f.write_str(self.as_str())
113    }
114}
115
116impl std::str::FromStr for RuntimeSelection {
117    type Err = String;
118
119    fn from_str(value: &str) -> Result<Self, Self::Err> {
120        match value {
121            "chrome" => Ok(Self::Chrome),
122            "alloy" => Ok(Self::Alloy),
123            _ => Err(format!(
124                "unsupported runtime '{value}': expected 'chrome' or 'alloy'"
125            )),
126        }
127    }
128}
129
130/// Errors that can occur while launching Chromium and completing initial
131/// bridge/session setup.
132#[derive(Debug, thiserror::Error)]
133pub enum StartChromiumError {
134    /// The requested runtime is recognized but not currently supported for startup.
135    #[error("unsupported chromium runtime '{runtime}': use 'chrome'")]
136    UnsupportedRuntime { runtime: RuntimeSelection },
137    /// Custom scheme registrations contained invalid launch-time input.
138    #[error("invalid custom scheme registration: {detail}")]
139    InvalidCustomScheme { detail: String },
140    /// The runtime bridge library or one of its required symbols could not be loaded.
141    #[error("failed to load cbf bridge: {0}")]
142    BridgeLoad(#[from] BridgeLoadError),
143    /// The bridge loaded but failed to allocate a client handle.
144    #[error("cbf_bridge_client_create returned null")]
145    BridgeClientCreateReturnedNull,
146    /// Preparing the inherited IPC channel for the child process failed.
147    #[error("failed to prepare IPC channel: {0}")]
148    PrepareChannel(#[source] BridgeError),
149    /// Random session-token generation failed before launch.
150    #[error("failed to generate session token: {0}")]
151    TokenGeneration(#[source] getrandom::Error),
152    /// Spawning the Chromium child process failed at the OS process layer.
153    #[error("failed to spawn chromium process: {0}")]
154    ProcessSpawn(#[source] std::io::Error),
155    /// The bridge client could not bind the inherited IPC endpoint after spawn.
156    #[error("failed to connect inherited IPC channel: {0}")]
157    ConnectInherited(#[source] BridgeError),
158    /// Bridge session authentication failed after the IPC connection was established.
159    #[error("failed to authenticate chromium bridge session: {0}")]
160    Authenticate(#[source] BridgeError),
161    /// The backend connected, but the higher-level browser session setup failed.
162    #[error("failed to initialize browser session: {0}")]
163    SessionConnect(#[source] Error),
164}
165
166fn validate_runtime_selection(runtime: RuntimeSelection) -> Result<(), StartChromiumError> {
167    if matches!(runtime, RuntimeSelection::Chrome) {
168        return Ok(());
169    }
170
171    Err(StartChromiumError::UnsupportedRuntime { runtime })
172}
173
174/// Options for launching the Chromium process.
175#[derive(Debug, Clone)]
176pub struct ChromiumProcessOptions {
177    /// Runtime path to use for startup.
178    ///
179    /// The default is `chrome`. `alloy` is currently reserved and will be
180    /// rejected by `start_chromium` until that runtime exists.
181    pub runtime: RuntimeSelection,
182    /// Path to the browser executable (e.g. "<Runtime>.app/Contents/MacOS/<Runtime>").
183    pub executable_path: PathBuf,
184    /// Path to the user data directory.
185    /// If provided, passed as `--user-data-dir=<path>`.
186    /// Crashpad dump storage is also redirected under
187    /// `<path>/Crashpad` via `--breakpad-dump-location=<path>/Crashpad`
188    /// so crash-related artifacts stay within the same app data root.
189    /// Prefer setting this explicitly unless you have a strong reason not to.
190    /// If `None`, Chromium may use a default profile location, which can conflict
191    /// with normal Chromium usage and risk profile data issues (for example,
192    /// profile/schema version mismatch).
193    pub user_data_dir: Option<String>,
194    /// Whether to enable logging.
195    /// If provided, passed as `--enable-logging=<stream>`.
196    /// e.g. "--enable-logging=stderr"
197    pub enable_logging: Option<String>,
198    /// Path to the log file.
199    /// If provided, passed as `--log-file=<path>`.
200    pub log_file: Option<String>,
201    /// Chromium VLOG verbosity.
202    /// If provided, passed as `--v=<level>`.
203    pub v: Option<i32>,
204    /// Per-module VLOG verbosity.
205    /// If provided, passed as `--vmodule=<pattern1=N,...>`.
206    pub vmodule: Option<String>,
207    /// Allow Chromium to create its default startup window.
208    ///
209    /// By default, CBF passes `--no-startup-window` to prevent Chromium's
210    /// built-in initial window from being created unexpectedly.
211    ///
212    /// This option is intentionally marked unsafe because enabling it can
213    /// interfere with CBF-controlled window lifecycle behavior.
214    pub unsafe_enable_startup_default_window: bool,
215    /// Additional arguments to pass to the browser process.
216    pub extra_args: Vec<String>,
217}
218
219/// Combined options for launching Chromium and connecting the backend.
220#[derive(Debug, Clone)]
221pub struct StartChromiumOptions {
222    /// Options for the Chromium child process.
223    pub process: ChromiumProcessOptions,
224    /// Options for backend IPC connection behavior.
225    pub backend: ChromiumBackendOptions,
226}
227
228fn build_custom_schemes_switch_value(
229    registrations: &[ChromeCustomSchemeRegistration],
230) -> Result<Option<String>, StartChromiumError> {
231    let mut scheme_names = BTreeSet::new();
232    for registration in registrations {
233        let scheme = registration.scheme.trim().to_ascii_lowercase();
234        if scheme.is_empty() {
235            return Err(StartChromiumError::InvalidCustomScheme {
236                detail: "custom scheme registration contains an empty scheme".to_owned(),
237            });
238        }
239        scheme_names.insert(scheme);
240    }
241
242    if scheme_names.is_empty() {
243        Ok(None)
244    } else {
245        Ok(Some(scheme_names.into_iter().collect::<Vec<_>>().join(",")))
246    }
247}
248
249/// A handle to the running Chromium process.
250///
251/// This struct holds the `std::process::Child` and allows managing its lifecycle.
252#[derive(Debug)]
253pub struct ChromiumProcess {
254    child: Child,
255}
256
257impl ChromiumProcess {
258    const WAIT_POLL_INTERVAL: Duration = Duration::from_millis(50);
259
260    /// Returns the process id of the browser process.
261    pub fn pid(&self) -> u32 {
262        self.child.id()
263    }
264
265    /// Forcefully kills the browser process.
266    pub fn kill(&mut self) -> std::io::Result<()> {
267        self.child.kill()
268    }
269
270    /// Requests browser process termination with `SIGTERM`.
271    #[cfg(unix)]
272    pub fn terminate(&self) -> std::io::Result<()> {
273        send_signal(self.pid(), libc::SIGTERM)
274    }
275
276    /// Waits for the browser process to exit.
277    pub fn wait_blocking(&mut self) -> std::io::Result<ExitStatus> {
278        block_on(self.child.status())
279    }
280
281    /// Attempts to check if the browser process has exited without blocking.
282    pub fn try_wait(&mut self) -> std::io::Result<Option<ExitStatus>> {
283        self.child.try_status()
284    }
285
286    /// Await the browser process exit status asynchronously.
287    pub async fn wait(&mut self) -> std::io::Result<ExitStatus> {
288        self.child.status().await
289    }
290
291    /// Polls for process exit until `timeout` elapses.
292    pub fn wait_for_exit_timeout(
293        &mut self,
294        timeout: Duration,
295    ) -> std::io::Result<Option<ExitStatus>> {
296        let deadline = Instant::now() + timeout;
297        loop {
298            if let Some(status) = self.try_wait()? {
299                return Ok(Some(status));
300            }
301            if Instant::now() >= deadline {
302                return Ok(None);
303            }
304            thread::sleep(
305                Self::WAIT_POLL_INTERVAL.min(deadline.saturating_duration_since(Instant::now())),
306            );
307        }
308    }
309}
310
311/// Shutdown strategy used when terminating a running Chromium runtime.
312#[derive(Debug, Clone, Copy, PartialEq, Eq)]
313pub enum ShutdownMode {
314    /// Request orderly shutdown through the browser session before escalating.
315    Graceful,
316    /// Skip orderly teardown and force the runtime toward immediate exit.
317    Force,
318}
319
320/// Current shutdown status tracked by the runtime's shared shutdown controller.
321#[derive(Debug, Clone, Copy, PartialEq, Eq)]
322pub enum ChromiumRuntimeShutdownState {
323    /// No shutdown has started.
324    Idle,
325    /// Graceful shutdown has started.
326    Graceful,
327    /// Forced shutdown has started.
328    Force,
329}
330
331impl ChromiumRuntimeShutdownState {
332    fn as_u8(self) -> u8 {
333        match self {
334            Self::Idle => 0,
335            Self::Graceful => 1,
336            Self::Force => 2,
337        }
338    }
339
340    fn from_u8(value: u8) -> Self {
341        match value {
342            1 => Self::Graceful,
343            2 => Self::Force,
344            _ => Self::Idle,
345        }
346    }
347
348    fn from_mode(mode: ShutdownMode) -> Self {
349        match mode {
350            ShutdownMode::Graceful => Self::Graceful,
351            ShutdownMode::Force => Self::Force,
352        }
353    }
354}
355
356/// Cloneable reader for observing runtime shutdown state from other threads.
357#[derive(Debug, Clone)]
358pub struct ChromiumRuntimeShutdownStateReader {
359    state: Arc<AtomicU8>,
360}
361
362impl ChromiumRuntimeShutdownStateReader {
363    /// Returns the latest shutdown state recorded by the runtime controller.
364    pub fn shutdown_state(&self) -> ChromiumRuntimeShutdownState {
365        ChromiumRuntimeShutdownState::from_u8(self.state.load(Ordering::Acquire))
366    }
367}
368
369/// Errors returned when installing process-wide signal handlers for a runtime.
370#[derive(Debug, thiserror::Error)]
371pub enum InstallSignalHandlersError {
372    #[error("signal handlers are already installed for a Chromium runtime")]
373    AlreadyInstalled,
374    #[error("failed to install signal handlers: {0}")]
375    Io(#[from] std::io::Error),
376}
377
378#[derive(Debug)]
379struct ShutdownController {
380    browser: BrowserHandle<ChromiumBackend>,
381    pid: u32,
382    next_shutdown_request_id: AtomicU64,
383    shutdown_state: Arc<AtomicU8>,
384    shutdown_started: AtomicBool,
385}
386
387impl ShutdownController {
388    const FORCE_WAIT_TIMEOUT: Duration = Duration::from_secs(3);
389    const TERM_WAIT_TIMEOUT: Duration = Duration::from_secs(1);
390
391    fn new(browser: BrowserHandle<ChromiumBackend>, pid: u32) -> Self {
392        Self {
393            browser,
394            pid,
395            next_shutdown_request_id: AtomicU64::new(1),
396            shutdown_state: Arc::new(AtomicU8::new(ChromiumRuntimeShutdownState::Idle.as_u8())),
397            shutdown_started: AtomicBool::new(false),
398        }
399    }
400
401    fn begin_shutdown(&self, mode: ShutdownMode) -> bool {
402        if self
403            .shutdown_started
404            .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
405            .is_ok()
406        {
407            self.shutdown_state.store(
408                ChromiumRuntimeShutdownState::from_mode(mode).as_u8(),
409                Ordering::Release,
410            );
411            true
412        } else {
413            false
414        }
415    }
416
417    fn shutdown_state(&self) -> ChromiumRuntimeShutdownState {
418        ChromiumRuntimeShutdownState::from_u8(self.shutdown_state.load(Ordering::Acquire))
419    }
420
421    fn shutdown_state_reader(&self) -> ChromiumRuntimeShutdownStateReader {
422        ChromiumRuntimeShutdownStateReader {
423            state: Arc::clone(&self.shutdown_state),
424        }
425    }
426
427    fn shutdown_via_pid(&self, mode: ShutdownMode) {
428        if !self.begin_shutdown(mode) {
429            return;
430        }
431
432        _ = match mode {
433            ShutdownMode::Graceful => self.browser.request_shutdown(
434                self.next_shutdown_request_id
435                    .fetch_add(1, Ordering::Relaxed),
436            ),
437            ShutdownMode::Force => self.browser.force_shutdown(),
438        };
439
440        if wait_for_pid_exit(self.pid, Self::FORCE_WAIT_TIMEOUT) {
441            return;
442        }
443
444        #[cfg(unix)]
445        {
446            let _ = send_signal(self.pid, libc::SIGTERM);
447        }
448
449        if wait_for_pid_exit(self.pid, Self::TERM_WAIT_TIMEOUT) {
450            return;
451        }
452
453        #[cfg(unix)]
454        {
455            let _ = send_signal(self.pid, libc::SIGKILL);
456        }
457    }
458}
459
460static SIGNAL_HANDLERS_INSTALLED: AtomicBool = AtomicBool::new(false);
461
462/// Owns a live Chromium session, its event stream, and the spawned child process.
463///
464/// `ChromiumRuntime` coordinates shutdown across the `cbf` session layer and
465/// the OS process, and can optionally install signal handlers that trigger
466/// forced termination on `SIGINT` or `SIGTERM`.
467#[derive(Debug)]
468pub struct ChromiumRuntime {
469    session: BrowserSession<ChromiumBackend>,
470    events: EventStream<ChromiumBackend>,
471    process: ChromiumProcess,
472    shutdown_controller: Arc<ShutdownController>,
473}
474
475impl ChromiumRuntime {
476    /// Wraps an already connected session, event stream, and child process into
477    /// a single runtime owner.
478    ///
479    /// This is typically constructed from the tuple returned by
480    /// [`start_chromium`].
481    pub fn new(
482        session: BrowserSession<ChromiumBackend>,
483        events: EventStream<ChromiumBackend>,
484        process: ChromiumProcess,
485    ) -> Self {
486        let shutdown_controller =
487            Arc::new(ShutdownController::new(session.handle(), process.pid()));
488        Self {
489            session,
490            events,
491            process,
492            shutdown_controller,
493        }
494    }
495
496    /// Returns the owned browser session for issuing commands and obtaining
497    /// backend handles.
498    pub fn session(&self) -> &BrowserSession<ChromiumBackend> {
499        &self.session
500    }
501
502    /// Returns a cloned event stream handle for receiving backend events.
503    ///
504    /// Callers commonly move this clone to a dedicated forwarding thread while
505    /// retaining the runtime itself for lifecycle management.
506    pub fn events(&self) -> EventStream<ChromiumBackend> {
507        self.events.clone()
508    }
509
510    /// Returns a shared reference to the spawned Chromium process handle.
511    pub fn process(&self) -> &ChromiumProcess {
512        &self.process
513    }
514
515    /// Returns a mutable reference to the spawned Chromium process handle.
516    ///
517    /// This is intended for advanced process-level operations such as waiting
518    /// for exit or inspecting process state directly.
519    pub fn process_mut(&mut self) -> &mut ChromiumProcess {
520        &mut self.process
521    }
522
523    /// Returns the current runtime shutdown state tracked by the shutdown
524    /// controller.
525    pub fn shutdown_state(&self) -> ChromiumRuntimeShutdownState {
526        self.shutdown_controller.shutdown_state()
527    }
528
529    /// Returns a cloneable reader that can observe shutdown progress from other
530    /// threads without borrowing the runtime.
531    pub fn shutdown_state_reader(&self) -> ChromiumRuntimeShutdownStateReader {
532        self.shutdown_controller.shutdown_state_reader()
533    }
534
535    /// Installs process-wide `SIGINT`/`SIGTERM` handlers that force runtime
536    /// shutdown when either signal is received.
537    ///
538    /// Handlers can only be installed once per process. A received signal uses
539    /// PID-based termination fallback so shutdown can continue even if the
540    /// normal event-processing path is stalled.
541    pub fn install_signal_handlers(&self) -> Result<(), InstallSignalHandlersError> {
542        if SIGNAL_HANDLERS_INSTALLED
543            .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
544            .is_err()
545        {
546            return Err(InstallSignalHandlersError::AlreadyInstalled);
547        }
548
549        let controller = Arc::clone(&self.shutdown_controller);
550        let signals = Signals::new([signal_hook::consts::SIGINT, signal_hook::consts::SIGTERM])
551            .inspect_err(|_| {
552                SIGNAL_HANDLERS_INSTALLED.store(false, Ordering::Release);
553            })?;
554
555        thread::spawn(move || {
556            let mut signals = signals;
557            if signals.forever().next().is_some() {
558                controller.shutdown_via_pid(ShutdownMode::Force);
559            }
560        });
561
562        Ok(())
563    }
564
565    /// Starts runtime shutdown using the requested mode and waits for the child
566    /// process to exit, escalating to OS-level termination if needed.
567    ///
568    /// Repeated calls after shutdown has already started are treated as no-ops.
569    /// On drop, the runtime automatically invokes this with
570    /// [`ShutdownMode::Force`].
571    pub fn shutdown(&mut self, mode: ShutdownMode) -> std::io::Result<()> {
572        if !self.shutdown_controller.begin_shutdown(mode) {
573            return Ok(());
574        }
575
576        let _ = match mode {
577            ShutdownMode::Graceful => self.session.close(),
578            ShutdownMode::Force => self.session.force_close(),
579        };
580
581        if self
582            .process
583            .wait_for_exit_timeout(ShutdownController::FORCE_WAIT_TIMEOUT)?
584            .is_some()
585        {
586            return Ok(());
587        }
588
589        #[cfg(unix)]
590        self.process.terminate()?;
591
592        if self
593            .process
594            .wait_for_exit_timeout(ShutdownController::TERM_WAIT_TIMEOUT)?
595            .is_some()
596        {
597            return Ok(());
598        }
599
600        match self.process.kill() {
601            Ok(()) => Ok(()),
602            Err(err) if err.kind() == std::io::ErrorKind::InvalidInput => Ok(()),
603            Err(err) => Err(err),
604        }
605    }
606}
607
608impl Drop for ChromiumRuntime {
609    fn drop(&mut self) {
610        let _ = self.shutdown(ShutdownMode::Force);
611    }
612}
613
614#[cfg(unix)]
615fn send_signal(pid: u32, signal: libc::c_int) -> std::io::Result<()> {
616    let result = unsafe { libc::kill(pid as libc::pid_t, signal) };
617    if result == 0 {
618        return Ok(());
619    }
620
621    let err = std::io::Error::last_os_error();
622    if matches!(err.raw_os_error(), Some(libc::ESRCH)) {
623        return Ok(());
624    }
625    Err(err)
626}
627
628#[cfg(unix)]
629fn process_exists(pid: u32) -> bool {
630    let result = unsafe { libc::kill(pid as libc::pid_t, 0) };
631    if result == 0 {
632        return true;
633    }
634
635    let err = std::io::Error::last_os_error();
636    !matches!(err.raw_os_error(), Some(libc::ESRCH))
637}
638
639#[cfg(not(unix))]
640fn process_exists(_pid: u32) -> bool {
641    false
642}
643
644fn wait_for_pid_exit(pid: u32, timeout: Duration) -> bool {
645    let deadline = Instant::now() + timeout;
646    while Instant::now() < deadline {
647        if !process_exists(pid) {
648            return true;
649        }
650        thread::sleep(
651            ChromiumProcess::WAIT_POLL_INTERVAL
652                .min(deadline.saturating_duration_since(Instant::now())),
653        );
654    }
655    !process_exists(pid)
656}
657
658/// Launches the Chromium process and connects to it via an inherited Mojo endpoint.
659///
660/// This function prepares the IPC channel, spawns the browser process with the
661/// channel handle argument, completes the Mojo connection, and authenticates with
662/// a freshly generated per-session token before returning a ready backend session.
663pub fn start_chromium(
664    options: StartChromiumOptions,
665    delegate: impl BackendDelegate,
666) -> Result<
667    (
668        BrowserSession<ChromiumBackend>,
669        EventStream<ChromiumBackend>,
670        ChromiumProcess,
671    ),
672    StartChromiumError,
673> {
674    let StartChromiumOptions { process, backend } = options;
675    let custom_schemes_switch_value =
676        build_custom_schemes_switch_value(&backend.custom_scheme_registrations)?;
677
678    let ChromiumProcessOptions {
679        runtime,
680        executable_path,
681        user_data_dir,
682        enable_logging,
683        log_file,
684        v,
685        vmodule,
686        unsafe_enable_startup_default_window,
687        extra_args,
688    } = process;
689
690    validate_runtime_selection(runtime)?;
691
692    // Production bundles launch the Chromium runtime from a separate app bundle.
693    // Align the host-side base bundle ID with that runtime bundle so Mach
694    // rendezvous uses the same bootstrap name on both sides.
695    #[cfg(target_os = "macos")]
696    if let Some(app_path) = executable_path
697        .parent()
698        .and_then(|p| p.parent())
699        .and_then(|p| p.parent())
700    {
701        let plist_path = app_path.join("Contents").join("Info.plist");
702        if let Ok(info) = plist::Value::from_file(&plist_path)
703            && let Some(bundle_id) = info
704                .as_dictionary()
705                .and_then(|d| d.get("CFBundleIdentifier"))
706                .and_then(|v| v.as_string())
707        {
708            tracing::debug!(bundle_id = %bundle_id, "overriding base bundle id for mach rendezvous");
709            _ = IpcClient::set_base_bundle_id(bundle_id);
710        }
711    }
712
713    // Create the bridge client handle and prepare the Mojo channel pair.
714    let inner = bridge().map(|bridge| unsafe { bridge.cbf_bridge_client_create() })?;
715    if inner.is_null() {
716        return Err(StartChromiumError::BridgeClientCreateReturnedNull);
717    }
718
719    let (remote_fd, switch_arg) = IpcClient::prepare_channel_and_lock().map_err(|error| {
720        cleanup_bridge_destroy(inner);
721        StartChromiumError::PrepareChannel(error)
722    })?;
723
724    // Generate a per-session token.
725    let mut token_bytes = [0u8; 32];
726    if let Err(error) = getrandom::fill(&mut token_bytes) {
727        IpcClient::abort_channel_launch();
728        cleanup_bridge_destroy(inner);
729        return Err(StartChromiumError::TokenGeneration(error));
730    }
731    let session_token: String = token_bytes.iter().map(|b| format!("{b:02x}")).collect();
732
733    let mut command = Command::new(&executable_path);
734
735    command.arg("--enable-features=Cbf");
736    command.arg(&switch_arg);
737    command.arg(format!("--cbf-session-token={session_token}"));
738    if let Some(custom_schemes_switch_value) = custom_schemes_switch_value {
739        command.arg(format!(
740            "--cbf-custom-schemes={custom_schemes_switch_value}"
741        ));
742    }
743
744    // Clear FD_CLOEXEC on the remote endpoint fd so it is inherited by the child.
745    #[cfg(unix)]
746    {
747        use std::os::unix::io::RawFd;
748        if remote_fd >= 0 {
749            unsafe { libc::fcntl(remote_fd as RawFd, libc::F_SETFD, 0) };
750        }
751    }
752
753    if let Some(user_data_dir) = &user_data_dir {
754        command.arg(format!("--user-data-dir={}", user_data_dir));
755        let crashpad_dir = PathBuf::from(user_data_dir).join("Crashpad");
756        command.arg(format!(
757            "--breakpad-dump-location={}",
758            crashpad_dir.to_string_lossy()
759        ));
760    }
761
762    if let Some(ref enable_logging) = enable_logging {
763        command.arg(format!("--enable-logging={}", enable_logging));
764    }
765
766    if let Some(log_file) = &log_file {
767        command.arg(format!("--log-file={}", log_file));
768    }
769
770    if let Some(v) = v {
771        command.arg(format!("--v={}", v));
772    }
773
774    if let Some(vmodule) = &vmodule {
775        command.arg(format!("--vmodule={}", vmodule));
776    }
777
778    if !unsafe_enable_startup_default_window {
779        command.arg("--no-startup-window");
780    }
781
782    command.args(&extra_args);
783
784    let child = match command.spawn() {
785        Ok(child) => child,
786        Err(error) => {
787            IpcClient::abort_channel_launch();
788            cleanup_bridge_destroy(inner);
789            return Err(StartChromiumError::ProcessSpawn(error));
790        }
791    };
792    let child_pid = child.id();
793
794    // Notify the bridge of the child PID: on macOS this registers the Mach
795    // port with the rendezvous server; on other platforms it is bookkeeping.
796    IpcClient::pass_child_pid_and_unlock(child_pid);
797
798    // Close the parent's copy of the remote fd after spawning.
799    #[cfg(unix)]
800    {
801        use std::os::unix::io::RawFd;
802        if remote_fd >= 0 {
803            unsafe { libc::close(remote_fd as RawFd) };
804        }
805    }
806
807    // Complete the Mojo handshake: send the OutgoingInvitation and bind the remote.
808    let client = match unsafe { IpcClient::connect_inherited(inner) } {
809        Ok(client) => client,
810        Err(error) => return Err(StartChromiumError::ConnectInherited(error)),
811    };
812
813    // Authenticate and set up the browser observer.
814    if let Err(error) = client.authenticate(&session_token) {
815        return Err(StartChromiumError::Authenticate(error));
816    }
817
818    let backend = ChromiumBackend::new(backend, client);
819    let (session, events) = BrowserSession::connect(backend, delegate, None)
820        .map_err(StartChromiumError::SessionConnect)?;
821
822    Ok((session, events, ChromiumProcess { child }))
823}
824
825fn cleanup_bridge_destroy(inner: *mut CbfBridgeClientHandle) {
826    if let Err(error) = bridge().map(|bridge| unsafe { bridge.cbf_bridge_client_destroy(inner) }) {
827        tracing::warn!(error = ?error, "failed to destroy bridge client after startup error");
828    }
829}
830
831#[cfg(test)]
832mod tests {
833    use super::*;
834    use crate::data::custom_scheme::ChromeCustomSchemeRegistration;
835    use async_process::Command;
836
837    #[test]
838    fn runtime_selection_defaults_to_chrome() {
839        assert_eq!(RuntimeSelection::default(), RuntimeSelection::Chrome);
840        assert_eq!(RuntimeSelection::default().to_string(), "chrome");
841    }
842
843    #[test]
844    fn runtime_selection_rejects_alloy_until_implemented() {
845        let err = validate_runtime_selection(RuntimeSelection::Alloy).unwrap_err();
846
847        match err {
848            StartChromiumError::UnsupportedRuntime { runtime } => {
849                assert_eq!(runtime, RuntimeSelection::Alloy);
850            }
851            other => panic!("unexpected error: {other:?}"),
852        }
853    }
854
855    #[test]
856    fn runtime_selection_parses_known_values() {
857        assert_eq!("chrome".parse(), Ok(RuntimeSelection::Chrome));
858        assert_eq!("alloy".parse(), Ok(RuntimeSelection::Alloy));
859    }
860
861    #[test]
862    fn build_custom_schemes_switch_value_dedupes_and_normalizes() {
863        let registrations = vec![
864            ChromeCustomSchemeRegistration {
865                scheme: "App".to_string(),
866                host: "simpleapp".to_string(),
867            },
868            ChromeCustomSchemeRegistration {
869                scheme: " app ".to_string(),
870                host: "other".to_string(),
871            },
872            ChromeCustomSchemeRegistration {
873                scheme: "Tool".to_string(),
874                host: "simpleapp".to_string(),
875            },
876        ];
877
878        let value = build_custom_schemes_switch_value(&registrations)
879            .expect("switch value should be built");
880
881        assert_eq!(value.as_deref(), Some("app,tool"));
882    }
883
884    #[test]
885    fn build_custom_schemes_switch_value_rejects_empty_scheme() {
886        let registrations = vec![ChromeCustomSchemeRegistration {
887            scheme: "   ".to_string(),
888            host: "simpleapp".to_string(),
889        }];
890
891        let err = build_custom_schemes_switch_value(&registrations).unwrap_err();
892
893        match err {
894            StartChromiumError::InvalidCustomScheme { detail } => {
895                assert_eq!(
896                    detail,
897                    "custom scheme registration contains an empty scheme"
898                );
899            }
900            other => panic!("unexpected error: {other:?}"),
901        }
902    }
903
904    #[cfg(unix)]
905    fn spawn_sleeping_process() -> ChromiumProcess {
906        let child = Command::new("sh")
907            .arg("-c")
908            .arg("sleep 30")
909            .spawn()
910            .expect("spawn sleeping process");
911        ChromiumProcess { child }
912    }
913
914    #[cfg(unix)]
915    #[test]
916    fn wait_for_exit_timeout_returns_none_while_process_is_alive() {
917        let mut process = spawn_sleeping_process();
918
919        let result = process
920            .wait_for_exit_timeout(Duration::from_millis(10))
921            .unwrap();
922
923        assert!(result.is_none());
924        process.kill().unwrap();
925        process.wait_blocking().unwrap();
926    }
927
928    #[cfg(unix)]
929    #[test]
930    fn terminate_requests_process_exit() {
931        let mut process = spawn_sleeping_process();
932
933        process.terminate().unwrap();
934
935        let status = process
936            .wait_for_exit_timeout(Duration::from_secs(2))
937            .unwrap()
938            .expect("terminated process should exit");
939        assert!(!status.success());
940    }
941
942    fn temp_path(name: &str) -> std::path::PathBuf {
943        let unique = std::time::SystemTime::now()
944            .duration_since(std::time::UNIX_EPOCH)
945            .unwrap()
946            .as_nanos();
947        std::env::temp_dir().join(format!("cbf-chrome-{name}-{unique}"))
948    }
949
950    #[test]
951    fn resolve_chromium_executable_from_runtime_dir_finds_single_runtime() {
952        let runtime_dir = temp_path("single-runtime");
953        let executable_path = runtime_dir
954            .join("Sample Engine.app")
955            .join("Contents")
956            .join("MacOS")
957            .join("Sample Engine");
958        std::fs::create_dir_all(executable_path.parent().unwrap()).unwrap();
959        std::fs::write(&executable_path, b"binary").unwrap();
960
961        let resolved = resolve_chromium_executable_from_runtime_dir(runtime_dir.clone());
962        assert_eq!(resolved.as_deref(), Some(executable_path.as_path()));
963
964        let _ = std::fs::remove_dir_all(runtime_dir);
965    }
966
967    #[test]
968    fn resolve_chromium_executable_from_runtime_dir_rejects_multiple_runtimes() {
969        let runtime_dir = temp_path("multiple-runtimes");
970        for name in ["One Engine", "Two Engine"] {
971            let executable_path = runtime_dir
972                .join(format!("{name}.app"))
973                .join("Contents")
974                .join("MacOS")
975                .join(name);
976            std::fs::create_dir_all(executable_path.parent().unwrap()).unwrap();
977            std::fs::write(executable_path, b"binary").unwrap();
978        }
979
980        assert!(resolve_chromium_executable_from_runtime_dir(runtime_dir.clone()).is_none());
981
982        let _ = std::fs::remove_dir_all(runtime_dir);
983    }
984}