diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index 598f7422f0..0295fcb751 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -129,7 +129,7 @@ cef = { version = "=146.4.1", default-features = false } openhuman_core = { path = "../..", package = "openhuman", default-features = false } [target.'cfg(unix)'.dependencies] -nix = { version = "0.29", default-features = false, features = ["signal"] } +nix = { version = "0.29", default-features = false, features = ["signal", "user"] } [target.'cfg(target_os = "macos")'.dependencies] objc2 = "0.6" diff --git a/app/src-tauri/src/cef_preflight.rs b/app/src-tauri/src/cef_preflight.rs index b6e71d2d5f..5050882c1c 100644 --- a/app/src-tauri/src/cef_preflight.rs +++ b/app/src-tauri/src/cef_preflight.rs @@ -1,11 +1,16 @@ -//! CEF cache-lock preflight check (macOS). +//! CEF cache-lock preflight check (macOS and Linux). //! //! When another OpenHuman instance is already running, it holds an exclusive -//! lock on the CEF user-data-dir at `~/Library/Caches/com.openhuman.app/cef`. +//! lock on the CEF user-data-dir. On macOS this is +//! `~/Library/Caches/com.openhuman.app/cef`; on Linux it is the path in +//! `OPENHUMAN_CEF_CACHE_PATH` (set by `cef_profile::prepare_process_cache_path` +//! before this module runs), falling back to `$XDG_CACHE_HOME//cef` or +//! `$HOME/.cache//cef` when the env var is absent. +//! //! The vendored `tauri-runtime-cef` crate calls `cef::initialize()` and //! asserts the result equals `1`; on lock collision it returns `0` and the //! assertion panics with a Rust backtrace and no actionable message -//! (see issue #864). +//! (Sentry OPENHUMAN-TAURI-K1 on Linux, issue #864 on macOS). //! //! This module runs *before* the Tauri builder constructs the runtime. //! It detects the lock-holder PID via Chromium's `SingletonLock` symlink and @@ -72,7 +77,12 @@ impl fmt::Display for CefLockError { impl std::error::Error for CefLockError {} -/// Resolves the macOS default CEF cache directory and runs the preflight. +/// Resolves the platform default CEF cache directory and runs the preflight. +/// +/// Checks `OPENHUMAN_CEF_CACHE_PATH` first (always set by +/// `cef_profile::prepare_process_cache_path` before this runs). Falls back +/// to the platform-specific default: `~/Library/Caches//cef` on macOS, +/// `$XDG_CACHE_HOME//cef` or `$HOME/.cache//cef` on Linux. pub fn check_default_cache() -> Result<(), CefLockError> { if let Some(configured) = std::env::var_os("OPENHUMAN_CEF_CACHE_PATH") { let configured = PathBuf::from(configured); @@ -84,10 +94,22 @@ pub fn check_default_cache() -> Result<(), CefLockError> { } let home = std::env::var_os("HOME").ok_or(CefLockError::NoHomeDir)?; - let cache_path = PathBuf::from(home) - .join("Library/Caches") + let home = PathBuf::from(home); + + #[cfg(target_os = "macos")] + let cache_path = home.join("Library/Caches").join(APP_IDENTIFIER).join("cef"); + + // On Linux: $XDG_CACHE_HOME//cef or $HOME/.cache//cef. + // This matches the fallback path in tauri-runtime-cef's CefRuntime::init + // (via `dirs::cache_dir()`). + #[cfg(target_os = "linux")] + let cache_path = std::env::var_os("XDG_CACHE_HOME") + .map(PathBuf::from) + .filter(|p| p.is_absolute()) + .unwrap_or_else(|| home.join(".cache")) .join(APP_IDENTIFIER) .join("cef"); + log::debug!("[cef-preflight] cache_path={}", cache_path.display()); check_cef_cache_lock(&cache_path) } @@ -201,6 +223,12 @@ mod tests { use super::*; use std::os::unix::fs::symlink; + // Shared lock for all tests that mutate process-global env vars. + // Each test previously had its own local `static ENV_LOCK`, allowing + // concurrent test threads to race on OPENHUMAN_CEF_CACHE_PATH / + // XDG_CACHE_HOME. A single module-level lock serialises them. + static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + #[test] fn parse_target_simple() { assert_eq!( @@ -310,4 +338,86 @@ mod tests { ); let _ = fs::remove_dir_all(&tmp); } + + /// `check_default_cache` must use `OPENHUMAN_CEF_CACHE_PATH` when set — + /// on both macOS and Linux the profile module always sets this before the + /// preflight runs, so the platform-specific fallback paths are irrelevant + /// in production, but the configured-path branch must work on all platforms. + #[test] + fn check_default_cache_uses_configured_env_path() { + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + + let prior = std::env::var_os("OPENHUMAN_CEF_CACHE_PATH"); + let tmp = fresh_tmp("default-cache-env"); + + std::env::set_var("OPENHUMAN_CEF_CACHE_PATH", &tmp); + let result = check_default_cache(); + + match prior { + Some(v) => std::env::set_var("OPENHUMAN_CEF_CACHE_PATH", v), + None => std::env::remove_var("OPENHUMAN_CEF_CACHE_PATH"), + } + + assert!(result.is_ok(), "expected Ok with no lock, got {result:?}"); + let _ = fs::remove_dir_all(&tmp); + } + + /// `check_default_cache` with env-path pointing to a dir holding a live lock + /// must return `CefLockError::Held`. + #[test] + fn check_default_cache_env_path_held_returns_err() { + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + + let prior = std::env::var_os("OPENHUMAN_CEF_CACHE_PATH"); + let tmp = fresh_tmp("default-cache-held"); + let me = std::process::id() as i32; + symlink(format!("testhost-{me}"), tmp.join("SingletonLock")).unwrap(); + + std::env::set_var("OPENHUMAN_CEF_CACHE_PATH", &tmp); + let result = check_default_cache(); + + match prior { + Some(v) => std::env::set_var("OPENHUMAN_CEF_CACHE_PATH", v), + None => std::env::remove_var("OPENHUMAN_CEF_CACHE_PATH"), + } + + match result { + Err(CefLockError::Held { pid, .. }) => assert_eq!(pid, me), + other => panic!("expected Held, got {other:?}"), + } + let _ = fs::remove_dir_all(&tmp); + } + + /// On Linux, `check_default_cache` without `OPENHUMAN_CEF_CACHE_PATH` set + /// must fall back to `$XDG_CACHE_HOME//cef` and return Ok when no lock + /// is present. + #[cfg(target_os = "linux")] + #[test] + fn check_default_cache_linux_xdg_fallback_no_lock() { + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + + let prior_cache = std::env::var_os("OPENHUMAN_CEF_CACHE_PATH"); + let prior_xdg = std::env::var_os("XDG_CACHE_HOME"); + std::env::remove_var("OPENHUMAN_CEF_CACHE_PATH"); + + // Redirect XDG_CACHE_HOME to a temp dir we control. + let tmp = fresh_tmp("linux-xdg-fallback"); + std::env::set_var("XDG_CACHE_HOME", &tmp); + + let result = check_default_cache(); + + std::env::remove_var("XDG_CACHE_HOME"); + match prior_cache { + Some(v) => std::env::set_var("OPENHUMAN_CEF_CACHE_PATH", v), + None => {} + } + match prior_xdg { + Some(v) => std::env::set_var("XDG_CACHE_HOME", v), + None => {} + } + + // No SingletonLock under tmp//cef — should be Ok. + assert!(result.is_ok(), "expected Ok with no lock, got {result:?}"); + let _ = fs::remove_dir_all(&tmp); + } } diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 3190492759..9d144b02cd 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -2,7 +2,7 @@ compile_error!("src-tauri host is desktop-only. Non-desktop targets are not supported."); mod cdp; -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "macos", target_os = "linux"))] mod cef_preflight; mod cef_profile; mod core_process; @@ -1593,8 +1593,57 @@ fn warn_if_wsl_x11_desktop_launch() { #[cfg(not(target_os = "linux"))] fn warn_if_wsl_x11_desktop_launch() {} +/// Returns `true` if a display server is available on Linux. +/// Testable pure function: takes the env-presence booleans directly. +#[cfg(any(target_os = "linux", test))] +fn linux_display_server_present(display: bool, wayland_display: bool) -> bool { + display || wayland_display +} + +/// Pre-CEF display-server check for Linux (Sentry OPENHUMAN-TAURI-K1). +/// +/// CEF/Chromium requires X11 (`DISPLAY`) or Wayland (`WAYLAND_DISPLAY`) to +/// initialise. Without either, `cef_initialize` returns 0 and the vendored +/// `tauri-runtime-cef` asserts `result == 1` → panic `left: 0, right: 1`. +/// This is fatal and silent on WSL2 without WSLg and on any headless Linux box. +/// Detect it here and exit with a clear message before `CefRuntime::init` runs. +#[cfg(target_os = "linux")] +fn check_linux_display_server() { + if linux_display_server_present( + has_non_empty_env("DISPLAY"), + has_non_empty_env("WAYLAND_DISPLAY"), + ) { + log::debug!( + "[cef-preflight] Linux display server present: DISPLAY={:?} WAYLAND_DISPLAY={:?}", + std::env::var("DISPLAY").ok(), + std::env::var("WAYLAND_DISPLAY").ok() + ); + return; + } + let msg = "[openhuman] no display server found (DISPLAY and WAYLAND_DISPLAY are both unset).\n\ + OpenHuman requires an X11 or Wayland display to run.\n\ + On WSL2: install WSLg or configure X11 forwarding from Windows.\n\ + Set DISPLAY (e.g. export DISPLAY=:0) or WAYLAND_DISPLAY before launching."; + log::error!( + "[cef-preflight] Linux display server missing — CEF cannot initialize \ + (OPENHUMAN-TAURI-K1): DISPLAY and WAYLAND_DISPLAY both unset" + ); + eprintln!("\n{msg}\n"); + std::process::exit(1); +} + +#[cfg(not(target_os = "linux"))] +fn check_linux_display_server() {} + type CefCommandLineArg = (&'static str, Option<&'static str>); +/// Returns `true` when the process is running as root (UID 0) on Linux. +/// Testable pure function; takes the uid directly. +#[cfg(any(target_os = "linux", test))] +fn linux_is_root_uid(uid: u32) -> bool { + uid == 0 +} + fn append_platform_cef_gpu_workarounds(args: &mut Vec, os: &str, arch: &str) { // Issue #1697: on Arch/Manjaro-family Linux systems, the AppImage can // abort during CEF GPU process startup when EGL context creation fails @@ -1620,6 +1669,28 @@ fn append_platform_cef_gpu_workarounds(args: &mut Vec, os: &s "[cef-startup] Intel macOS detected: adding --disable-gpu-compositing (issue #1012)" ); } + + // Sentry OPENHUMAN-TAURI-K1: `cef::initialize` returns 0 when running as + // root (uid 0) on Linux unless `--no-sandbox` is passed as a command-line + // argument. The `no_sandbox: 1` field in `cef::Settings` disables the + // sub-process sandbox but does NOT satisfy Chromium's separate root-user + // check in the browser process — that check requires the CLI flag. + // + // This hits CI / coder-bot / Docker environments (e.g. + // `/root/.hermes/profiles/coder-bot/home`) that run as root inside a + // container. Without the flag, `cef_initialize` returns 0 and the vendored + // runtime assertion fires (`left: 0, right: 1`). + #[cfg(target_os = "linux")] + { + let uid = nix::unistd::getuid().as_raw(); + if os == "linux" && linux_is_root_uid(uid) { + args.push(("--no-sandbox", None)); + log::info!( + "[cef-startup] running as root (uid=0) on Linux: adding --no-sandbox \ + (OPENHUMAN-TAURI-K1)" + ); + } + } } pub fn run() { @@ -1812,6 +1883,9 @@ pub fn run() { } warn_if_wsl_x11_desktop_launch(); + // Exit before CEF if no display server is available — prevents the + // `assert_eq!(cef_initialize(…), 1)` panic (OPENHUMAN-TAURI-K1). + check_linux_display_server(); // The vendored tauri-cef dev-server proxy builds a reqwest 0.13 client // (see vendor/tauri-cef/crates/tauri/src/protocol/tauri.rs) which calls @@ -1899,7 +1973,12 @@ pub fn run() { #[cfg(target_os = "macos")] process_recovery::reap_stale_openhuman_processes(); - #[cfg(target_os = "macos")] + // CEF cache-lock preflight: if another OpenHuman instance holds the CEF + // user-data-dir SingletonLock, `cef_initialize` returns 0 and the vendored + // runtime panics (`left: 0, right: 1`). Catch the collision here and exit + // cleanly. Stale locks (PID dead) are removed so crashed processes don't + // block subsequent launches. macOS: issue #864. Linux: OPENHUMAN-TAURI-K1. + #[cfg(any(target_os = "macos", target_os = "linux"))] if let Err(e) = cef_preflight::check_default_cache() { eprintln!("\n[openhuman] {e}\n"); std::process::exit(1); @@ -3204,6 +3283,41 @@ mod tests { assert!(!should_warn_for_wsl_x11_desktop(true, true, false, true)); } + // ------------------------------------------------------------------------- + // Linux display-server pre-flight (Sentry OPENHUMAN-TAURI-K1) + // ------------------------------------------------------------------------- + + #[test] + fn linux_display_present_with_x11() { + assert!(linux_display_server_present(true, false)); + } + + #[test] + fn linux_display_present_with_wayland() { + assert!(linux_display_server_present(false, true)); + } + + #[test] + fn linux_display_present_with_both() { + assert!(linux_display_server_present(true, true)); + } + + #[test] + fn linux_display_absent_without_either() { + assert!(!linux_display_server_present(false, false)); + } + + #[test] + fn linux_root_uid_detected() { + assert!(linux_is_root_uid(0)); + } + + #[test] + fn linux_non_root_uid_not_detected() { + assert!(!linux_is_root_uid(1000)); + assert!(!linux_is_root_uid(1)); + } + // ------------------------------------------------------------------------- // Platform constants (issue #1012 Sentry tagging) // -------------------------------------------------------------------------