1use 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
40pub 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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
92pub enum RuntimeSelection {
93 #[default]
95 Chrome,
96 Alloy,
98}
99
100impl RuntimeSelection {
101 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#[derive(Debug, thiserror::Error)]
133pub enum StartChromiumError {
134 #[error("unsupported chromium runtime '{runtime}': use 'chrome'")]
136 UnsupportedRuntime { runtime: RuntimeSelection },
137 #[error("invalid custom scheme registration: {detail}")]
139 InvalidCustomScheme { detail: String },
140 #[error("failed to load cbf bridge: {0}")]
142 BridgeLoad(#[from] BridgeLoadError),
143 #[error("cbf_bridge_client_create returned null")]
145 BridgeClientCreateReturnedNull,
146 #[error("failed to prepare IPC channel: {0}")]
148 PrepareChannel(#[source] BridgeError),
149 #[error("failed to generate session token: {0}")]
151 TokenGeneration(#[source] getrandom::Error),
152 #[error("failed to spawn chromium process: {0}")]
154 ProcessSpawn(#[source] std::io::Error),
155 #[error("failed to connect inherited IPC channel: {0}")]
157 ConnectInherited(#[source] BridgeError),
158 #[error("failed to authenticate chromium bridge session: {0}")]
160 Authenticate(#[source] BridgeError),
161 #[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#[derive(Debug, Clone)]
176pub struct ChromiumProcessOptions {
177 pub runtime: RuntimeSelection,
182 pub executable_path: PathBuf,
184 pub user_data_dir: Option<String>,
194 pub enable_logging: Option<String>,
198 pub log_file: Option<String>,
201 pub v: Option<i32>,
204 pub vmodule: Option<String>,
207 pub unsafe_enable_startup_default_window: bool,
215 pub extra_args: Vec<String>,
217}
218
219#[derive(Debug, Clone)]
221pub struct StartChromiumOptions {
222 pub process: ChromiumProcessOptions,
224 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#[derive(Debug)]
253pub struct ChromiumProcess {
254 child: Child,
255}
256
257impl ChromiumProcess {
258 const WAIT_POLL_INTERVAL: Duration = Duration::from_millis(50);
259
260 pub fn pid(&self) -> u32 {
262 self.child.id()
263 }
264
265 pub fn kill(&mut self) -> std::io::Result<()> {
267 self.child.kill()
268 }
269
270 #[cfg(unix)]
272 pub fn terminate(&self) -> std::io::Result<()> {
273 send_signal(self.pid(), libc::SIGTERM)
274 }
275
276 pub fn wait_blocking(&mut self) -> std::io::Result<ExitStatus> {
278 block_on(self.child.status())
279 }
280
281 pub fn try_wait(&mut self) -> std::io::Result<Option<ExitStatus>> {
283 self.child.try_status()
284 }
285
286 pub async fn wait(&mut self) -> std::io::Result<ExitStatus> {
288 self.child.status().await
289 }
290
291 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
313pub enum ShutdownMode {
314 Graceful,
316 Force,
318}
319
320#[derive(Debug, Clone, Copy, PartialEq, Eq)]
322pub enum ChromiumRuntimeShutdownState {
323 Idle,
325 Graceful,
327 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#[derive(Debug, Clone)]
358pub struct ChromiumRuntimeShutdownStateReader {
359 state: Arc<AtomicU8>,
360}
361
362impl ChromiumRuntimeShutdownStateReader {
363 pub fn shutdown_state(&self) -> ChromiumRuntimeShutdownState {
365 ChromiumRuntimeShutdownState::from_u8(self.state.load(Ordering::Acquire))
366 }
367}
368
369#[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#[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 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 pub fn session(&self) -> &BrowserSession<ChromiumBackend> {
499 &self.session
500 }
501
502 pub fn events(&self) -> EventStream<ChromiumBackend> {
507 self.events.clone()
508 }
509
510 pub fn process(&self) -> &ChromiumProcess {
512 &self.process
513 }
514
515 pub fn process_mut(&mut self) -> &mut ChromiumProcess {
520 &mut self.process
521 }
522
523 pub fn shutdown_state(&self) -> ChromiumRuntimeShutdownState {
526 self.shutdown_controller.shutdown_state()
527 }
528
529 pub fn shutdown_state_reader(&self) -> ChromiumRuntimeShutdownStateReader {
532 self.shutdown_controller.shutdown_state_reader()
533 }
534
535 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 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
658pub 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 #[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 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 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 #[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 IpcClient::pass_child_pid_and_unlock(child_pid);
797
798 #[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 let client = match unsafe { IpcClient::connect_inherited(inner) } {
809 Ok(client) => client,
810 Err(error) => return Err(StartChromiumError::ConnectInherited(error)),
811 };
812
813 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(®istrations)
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(®istrations).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}