From 116fa8683812b008333d2a6f2ab14d68c9e2fb76 Mon Sep 17 00:00:00 2001 From: oxoxDev <164490987+oxoxDev@users.noreply.github.com> Date: Fri, 24 Apr 2026 22:07:49 +0530 Subject: [PATCH] fix(cef): preflight cache-lock check on macOS (#864) (#879) --- app/src-tauri/src/cef_preflight.rs | 304 +++++++++++++++++++++++++++++ app/src-tauri/src/lib.rs | 15 ++ docs/BUILDING.md | 36 ++++ 3 files changed, 355 insertions(+) create mode 100644 app/src-tauri/src/cef_preflight.rs diff --git a/app/src-tauri/src/cef_preflight.rs b/app/src-tauri/src/cef_preflight.rs new file mode 100644 index 000000000..b474b31b7 --- /dev/null +++ b/app/src-tauri/src/cef_preflight.rs @@ -0,0 +1,304 @@ +//! CEF cache-lock preflight check (macOS). +//! +//! 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`. +//! 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). +//! +//! This module runs *before* the Tauri builder constructs the runtime. +//! It detects the lock-holder PID via Chromium's `SingletonLock` symlink and +//! either: +//! - returns [`CefLockError::Held`] when a live process owns the lock, or +//! - removes a stale lock (PID no longer alive) and returns Ok. +//! +//! Stale-lock cleanup mirrors Chromium's own startup behavior so dev startup +//! is not blocked by crashed processes. + +use std::fmt; +use std::fs; +use std::path::{Path, PathBuf}; + +use nix::sys::signal::kill; +use nix::unistd::Pid; + +/// Bundle identifier from `tauri.conf.json`. Must match `bundle.identifier` — +/// the vendored `tauri-runtime-cef` derives the cache directory as +/// `dirs::cache_dir() / / cef`. If `tauri.conf.json` ever changes +/// the bundle identifier, update this constant too. +pub const APP_IDENTIFIER: &str = "com.openhuman.app"; + +/// Errors returned by the preflight check. +#[derive(Debug)] +pub enum CefLockError { + /// Another live process holds the CEF cache lock. + Held { + pid: i32, + host: String, + cache_path: PathBuf, + }, + /// `$HOME` not set — cannot resolve default cache path. Treated as + /// non-fatal at the call site (preflight is best-effort). + NoHomeDir, +} + +impl fmt::Display for CefLockError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Held { + pid, + host, + cache_path, + } => write!( + f, + "CEF cache at {} is held by another OpenHuman instance \ + (host {}, pid {}).\n\ + Quit the running instance and try again.\n\ + Workaround:\n \ + pkill -f \"OpenHuman.app/Contents\"\n \ + pkill -f \"openhuman-core\"", + cache_path.display(), + host, + pid, + ), + Self::NoHomeDir => write!( + f, + "$HOME not set — cannot resolve CEF cache path for preflight" + ), + } + } +} + +impl std::error::Error for CefLockError {} + +/// Resolves the macOS default CEF cache directory and runs the preflight. +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") + .join(APP_IDENTIFIER) + .join("cef"); + log::debug!("[cef-preflight] cache_path={}", cache_path.display()); + check_cef_cache_lock(&cache_path) +} + +/// Inspects `/SingletonLock` (Chromium symlink). If present and +/// the target PID is still alive, returns [`CefLockError::Held`]. If the lock +/// is stale (PID dead), removes it and returns Ok — matches Chromium's own +/// startup recovery behavior. +pub fn check_cef_cache_lock(cache_path: &Path) -> Result<(), CefLockError> { + let lock_path = cache_path.join("SingletonLock"); + + // `symlink_metadata` does not follow symlinks — we want to know whether + // the symlink itself exists. CEF/Chromium lays this down as a symlink + // whose target string encodes the lock-holder. + let meta = match fs::symlink_metadata(&lock_path) { + Ok(m) => m, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + log::debug!( + "[cef-preflight] no SingletonLock at {}", + lock_path.display() + ); + return Ok(()); + } + Err(e) => { + log::warn!( + "[cef-preflight] cannot stat {}: {} — assuming no lock", + lock_path.display(), + e + ); + return Ok(()); + } + }; + + if !meta.file_type().is_symlink() { + log::warn!( + "[cef-preflight] {} exists but is not a symlink — skipping check", + lock_path.display() + ); + return Ok(()); + } + + let target = match fs::read_link(&lock_path) { + Ok(t) => t, + Err(e) => { + log::warn!( + "[cef-preflight] cannot read symlink {}: {} — skipping check", + lock_path.display(), + e + ); + return Ok(()); + } + }; + + let target_str = target.to_string_lossy(); + let Some((host, pid)) = parse_lock_target(&target_str) else { + log::warn!( + "[cef-preflight] unrecognized lock target format: {:?}", + target_str + ); + return Ok(()); + }; + + if is_pid_alive(pid) { + log::error!( + "[cef-preflight] CEF cache held by host={} pid={} at {}", + host, + pid, + cache_path.display() + ); + return Err(CefLockError::Held { + pid, + host, + cache_path: cache_path.to_path_buf(), + }); + } + + log::warn!( + "[cef-preflight] removing stale lock at {} (pid {} not alive)", + lock_path.display(), + pid + ); + if let Err(e) = fs::remove_file(&lock_path) { + log::warn!( + "[cef-preflight] failed to remove stale lock {}: {}", + lock_path.display(), + e + ); + } + Ok(()) +} + +/// Parses Chromium's `SingletonLock` symlink target — `-`. +/// Hostnames may contain dashes; the rightmost dash is the separator. +pub fn parse_lock_target(target: &str) -> Option<(String, i32)> { + let (host, pid_str) = target.rsplit_once('-')?; + let pid: i32 = pid_str.parse().ok()?; + if host.is_empty() || pid <= 0 { + return None; + } + Some((host.to_string(), pid)) +} + +/// Returns true iff a PID is still a live process visible to us. Sends signal +/// 0 (POSIX existence check) — does not actually deliver a signal. +pub fn is_pid_alive(pid: i32) -> bool { + matches!(kill(Pid::from_raw(pid), None), Ok(())) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::os::unix::fs::symlink; + + #[test] + fn parse_target_simple() { + assert_eq!( + parse_lock_target("myhost-12345"), + Some(("myhost".into(), 12345)) + ); + } + + #[test] + fn parse_target_with_dashes_in_host() { + assert_eq!( + parse_lock_target("my-fancy-host-99"), + Some(("my-fancy-host".into(), 99)) + ); + } + + #[test] + fn parse_target_pid_not_int() { + assert_eq!(parse_lock_target("just-a-name"), None); + } + + #[test] + fn parse_target_empty_pid() { + assert_eq!(parse_lock_target("host-"), None); + } + + #[test] + fn parse_target_empty_host() { + assert_eq!(parse_lock_target("-12345"), None); + } + + fn fresh_tmp(tag: &str) -> PathBuf { + let tmp = std::env::temp_dir().join(format!( + "oh-cef-preflight-{}-{}-{}", + tag, + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + let _ = fs::remove_dir_all(&tmp); + fs::create_dir_all(&tmp).expect("create tmp dir"); + tmp + } + + #[test] + fn no_lock_returns_ok() { + let tmp = fresh_tmp("nolock"); + assert!(check_cef_cache_lock(&tmp).is_ok()); + let _ = fs::remove_dir_all(&tmp); + } + + #[test] + fn lock_held_by_live_pid_returns_err() { + let tmp = fresh_tmp("live"); + let me = std::process::id() as i32; + symlink(format!("livehost-{me}"), tmp.join("SingletonLock")).unwrap(); + + match check_cef_cache_lock(&tmp) { + Err(CefLockError::Held { pid, host, .. }) => { + assert_eq!(pid, me); + assert_eq!(host, "livehost"); + } + other => panic!("expected Held, got {other:?}"), + } + let _ = fs::remove_dir_all(&tmp); + } + + #[test] + fn lock_stale_dead_pid_returns_ok_and_removes() { + let tmp = fresh_tmp("stale"); + // PID 2147483646 (~i32::MAX-1) is far beyond any plausible live PID. + symlink("deadhost-2147483646", tmp.join("SingletonLock")).unwrap(); + + let lock = tmp.join("SingletonLock"); + assert!( + fs::symlink_metadata(&lock).is_ok(), + "lock should exist before" + ); + + let res = check_cef_cache_lock(&tmp); + assert!(res.is_ok(), "expected Ok, got {res:?}"); + assert!( + fs::symlink_metadata(&lock).is_err(), + "stale lock should have been removed" + ); + let _ = fs::remove_dir_all(&tmp); + } + + #[test] + fn lock_with_garbage_target_skips() { + let tmp = fresh_tmp("garbage"); + symlink("not-a-valid-format", tmp.join("SingletonLock")).unwrap(); + + // "not-a-valid-format" rsplit_once('-') -> ("not-a-valid", "format") + // "format".parse::() fails -> parse_lock_target returns None -> + // skipped, returns Ok and leaves the lock alone. + let res = check_cef_cache_lock(&tmp); + assert!( + res.is_ok(), + "expected Ok on unparseable target, got {res:?}" + ); + assert!( + fs::symlink_metadata(tmp.join("SingletonLock")).is_ok(), + "unparseable lock must NOT be removed" + ); + let _ = fs::remove_dir_all(&tmp); + } +} diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 9fc453954..32ed0fb64 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -3,6 +3,8 @@ compile_error!("src-tauri host is desktop-only. Non-desktop targets are not supp #[cfg(feature = "cef")] mod cdp; +#[cfg(all(feature = "cef", target_os = "macos"))] +mod cef_preflight; mod core_process; mod core_update; #[cfg(feature = "cef")] @@ -556,6 +558,19 @@ pub fn run() { .parse_filters(&default_filter) .try_init(); + // CEF cache-lock preflight (macOS only): if another OpenHuman instance + // is already holding the CEF user-data-dir, the vendored + // `tauri-runtime-cef` panics inside `cef::initialize` with a Rust + // backtrace and no actionable message (issue #864). Catch the collision + // here and exit cleanly with a message that names the lock-holder PID + // and the workaround. Stale locks (PID dead) are removed and we + // continue, matching Chromium's own startup recovery. + #[cfg(all(feature = "cef", target_os = "macos"))] + if let Err(e) = cef_preflight::check_default_cache() { + eprintln!("\n[openhuman] {e}\n"); + std::process::exit(1); + } + // Runtime selection: default build uses wry (WKWebView on macOS), the // `cef` feature swaps to Chromium Embedded Framework. The switch is at // Builder construction only — everything downstream (plugins, commands, diff --git a/docs/BUILDING.md b/docs/BUILDING.md index 2fcb64885..5fb29057d 100644 --- a/docs/BUILDING.md +++ b/docs/BUILDING.md @@ -82,3 +82,39 @@ Manual download links (all platforms): - Website: https://tinyhuman.ai/openhuman - Latest release: https://github.com/tinyhumansai/openhuman/releases/latest + +## Troubleshooting + +### macOS: `yarn dev:app` exits with "CEF cache is held by another OpenHuman instance" + +**Symptom** + +`yarn dev:app` (or any debug build of the Tauri shell) exits before the window appears with a message like: + +``` +[openhuman] CEF cache at /Users//Library/Caches/com.openhuman.app/cef is held by another OpenHuman instance (host , pid 12345). +Quit the running instance and try again. +Workaround: + pkill -f "OpenHuman.app/Contents" + pkill -f "openhuman-core" +``` + +**Cause** + +CEF (Chromium Embedded Framework) holds an exclusive lock on its user-data directory via a `SingletonLock` symlink under `~/Library/Caches/com.openhuman.app/cef`. Both the installed `.app` bundle and the dev binary use the same identifier (`com.openhuman.app`), so they cannot run side-by-side. Without the preflight, `cef::initialize` returns failure and the vendored `tauri-runtime-cef` panics with a Rust backtrace and no actionable message (this was issue #864 before the preflight landed). + +**Fix** + +Quit the other OpenHuman instance and re-run. Fastest path: + +```bash +pkill -f "OpenHuman.app/Contents" +pkill -f "openhuman-core" +yarn dev:app +``` + +If the lock is left behind by a crashed process (PID no longer alive), the preflight removes the stale `SingletonLock` automatically and dev startup proceeds — no manual cleanup required. + +**Known limitation** + +Dev and release builds still share `com.openhuman.app` as the cache identifier. Isolating dev to a separate `com.openhuman.app.dev` cache requires changes to the vendored `tauri-runtime-cef` (cache path is built inside the runtime from the bundle identifier, not exposed to the openhuman shell). Tracked as a follow-up to #864.