mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 23:14:37 +00:00
424 lines
14 KiB
Rust
424 lines
14 KiB
Rust
//! 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. 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/<id>/cef` or
|
|
//! `$HOME/.cache/<id>/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
|
|
//! (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
|
|
//! 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() / <identifier> / 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 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/<id>/cef` on macOS,
|
|
/// `$XDG_CACHE_HOME/<id>/cef` or `$HOME/.cache/<id>/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);
|
|
log::debug!(
|
|
"[cef-preflight] using configured cache_path={}",
|
|
configured.display()
|
|
);
|
|
return check_cef_cache_lock(&configured);
|
|
}
|
|
|
|
let home = std::env::var_os("HOME").ok_or(CefLockError::NoHomeDir)?;
|
|
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/<id>/cef or $HOME/.cache/<id>/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)
|
|
}
|
|
|
|
/// Inspects `<cache_path>/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 — `<hostname>-<pid>`.
|
|
/// 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;
|
|
|
|
// 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!(
|
|
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::<i32>() 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);
|
|
}
|
|
|
|
/// `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/<id>/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/<id>/cef — should be Ok.
|
|
assert!(result.is_ok(), "expected Ok with no lock, got {result:?}");
|
|
let _ = fs::remove_dir_all(&tmp);
|
|
}
|
|
}
|