Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion app/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,6 @@ semver = "1"

log = "0.4"
env_logger = "0.11"
base64 = "0.22"

# Used by the imessage_scanner module.
anyhow = "1.0"
Expand Down
304 changes: 304 additions & 0 deletions app/src-tauri/src/cef_preflight.rs
Original file line number Diff line number Diff line change
@@ -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);
}
}
15 changes: 15 additions & 0 deletions app/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down Expand Up @@ -554,6 +556,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);
}
Comment on lines +559 to +570

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Minor inconsistency: NoHomeDir handling differs from its documentation.

The CefLockError::NoHomeDir variant is documented in cef_preflight.rs (lines 41-43) as "Treated as non-fatal at the call site (preflight is best-effort)", but this code exits with code 1 for any error including NoHomeDir.

If NoHomeDir should truly be non-fatal (allow the app to proceed when $HOME is unset), consider matching on the error variant:

🔧 Proposed fix to make NoHomeDir non-fatal
     #[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);
+    if let Err(e) = cef_preflight::check_default_cache() {
+        match &e {
+            cef_preflight::CefLockError::Held { .. } => {
+                eprintln!("\n[openhuman] {e}\n");
+                std::process::exit(1);
+            }
+            cef_preflight::CefLockError::NoHomeDir => {
+                log::warn!("[cef-preflight] {e} — continuing anyway");
+            }
+        }
     }

Alternatively, update the doc comment in cef_preflight.rs to reflect that NoHomeDir is actually fatal.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src-tauri/src/lib.rs` around lines 557 - 568, The current call to
cef_preflight::check_default_cache() treats any Err as fatal and exits, which
contradicts the cef_preflight::CefLockError::NoHomeDir documentation that says
it should be non-fatal; change the error handling to match on the returned error
from cef_preflight::check_default_cache() and only call eprintln! and
std::process::exit(1) for fatal variants, while allowing CefLockError::NoHomeDir
to be ignored (i.e., log at a lower level or skip exiting) so the app can
continue when HOME is unset; locate the call site around
cef_preflight::check_default_cache() in lib.rs and adjust the match to branch on
CefLockError::NoHomeDir vs other errors.


// 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,
Expand Down
36 changes: 36 additions & 0 deletions docs/BUILDING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Loading