mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
@@ -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() / <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 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 `<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;
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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/<you>/Library/Caches/com.openhuman.app/cef is held by another OpenHuman instance (host <hostname>, 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.
|
||||
|
||||
Reference in New Issue
Block a user