diff --git a/.github/tauri-cef-expected-sha b/.github/tauri-cef-expected-sha index 8dac2a865..fc80ee845 100644 --- a/.github/tauri-cef-expected-sha +++ b/.github/tauri-cef-expected-sha @@ -1 +1 @@ -c90c8a330056286e7c0d05439ae3d4527fa4fafe +5ec3d8836cbae300e36b7a9c0d302a65de92c58d diff --git a/app/src-tauri/src/cef_preflight.rs b/app/src-tauri/src/cef_preflight.rs index 5050882c1..e5a7f770f 100644 --- a/app/src-tauri/src/cef_preflight.rs +++ b/app/src-tauri/src/cef_preflight.rs @@ -24,10 +24,22 @@ use std::fmt; use std::fs; use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; use nix::sys::signal::kill; use nix::unistd::Pid; +/// Maximum time to wait for a live lock-holder to release the CEF cache before +/// giving up. Mirrors the Windows `cef_singleton_wait` budget so the +/// relaunch-race behaviour is symmetric across platforms — kept short so a +/// genuinely stuck holder can't hang startup; the common race clears well +/// under a second. +const WAIT_BUDGET: Duration = Duration::from_secs(5); +/// First poll backoff; doubles each attempt up to [`BACKOFF_CAP`]. +const BACKOFF_BASE: Duration = Duration::from_millis(100); +/// Upper bound on a single backoff sleep so we keep polling responsively. +const BACKOFF_CAP: Duration = Duration::from_millis(500); + /// 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 @@ -55,18 +67,28 @@ impl fmt::Display for CefLockError { 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, - ), + } => { + // The force-quit hint is platform-specific: macOS runs from an + // `.app` bundle, Linux from a plain binary. `cef_preflight` is + // compiled for both (`cfg(any(macos, linux))`), so a hardcoded + // macOS `pkill` pattern would mislead Linux users. + let workaround = if cfg!(target_os = "macos") { + "pkill -f \"OpenHuman.app/Contents\"\n pkill -f \"openhuman-core\"" + } else { + "pkill -f openhuman\n pkill -f openhuman-core" + }; + write!( + f, + "CEF cache at {} is held by another OpenHuman instance \ + (host {}, pid {}).\n\ + Quit the running instance and try again.\n\ + Workaround:\n {}", + cache_path.display(), + host, + pid, + workaround, + ) + } Self::NoHomeDir => write!( f, "$HOME not set — cannot resolve CEF cache path for preflight" @@ -218,6 +240,88 @@ pub fn is_pid_alive(pid: i32) -> bool { matches!(kill(Pid::from_raw(pid), None), Ok(())) } +/// Exponential backoff for poll `attempt` (0-based), capped at [`BACKOFF_CAP`]. +/// Pure + saturating so it never overflows on a high attempt count. Mirrors +/// the Windows `cef_singleton_wait` backoff. +fn backoff_delay(attempt: u32) -> Duration { + let factor = 1u32.checked_shl(attempt).unwrap_or(u32::MAX); + BACKOFF_BASE + .checked_mul(factor) + .unwrap_or(BACKOFF_CAP) + .min(BACKOFF_CAP) +} + +/// Bounded wait for the CEF cache lock to clear (macOS + Linux). +/// +/// The one-shot [`check_default_cache`] used to `exit(1)` the moment it saw a +/// live lock-holder. But the dominant TAURI-RUST-F cause is a *sequential +/// relaunch race*: the prior instance is mid-teardown and releases the lock +/// within a second (auto-update relaunch, fast quit+reopen, a restart flow). +/// Polling for a short budget lets that resolve seamlessly instead of killing +/// the relaunch — the macOS/Linux analogue of the Windows pre-CEF wait. +/// +/// Outcomes: +/// * lock clears (or is stale and removed) → return; caller proceeds to +/// `cef::initialize()`. +/// * cache path unresolved ([`CefLockError::NoHomeDir`]) → nothing to wait +/// on; proceed best-effort (matches the prior behaviour). +/// * lock still held by a live process after [`WAIT_BUDGET`] → a genuine +/// "another instance is running" situation: print the actionable message +/// and exit cleanly with code **0** (not a crash; `exit(1)` previously +/// made relaunch wrappers treat it as a failure). +/// +/// This is defense-in-depth, never the only guard: any failure that still +/// reaches `cef::initialize()` (lock freed then re-acquired between the check +/// and init, or a GPU/sandbox/permission failure) is caught by the runtime's +/// `cef_init_guard`, which shows a dialog and exits cleanly. Together they make +/// the `assertion left == right` panic impossible regardless of cause. +pub fn wait_for_cache_release() { + let start = Instant::now(); + let mut attempt: u32 = 0; + + loop { + match check_default_cache() { + Ok(()) => { + if attempt > 0 { + log::info!( + "[cef-preflight] CEF cache lock released after {} poll(s) ({} ms); proceeding to CEF init", + attempt, + start.elapsed().as_millis() + ); + } + return; + } + Err(CefLockError::NoHomeDir) => { + log::warn!("[cef-preflight] {}", CefLockError::NoHomeDir); + return; + } + Err(held) => { + let elapsed = start.elapsed(); + if elapsed >= WAIT_BUDGET { + log::error!( + "[cef-preflight] CEF cache still held after {} ms budget; exiting cleanly instead of initializing into a locked cache (TAURI-RUST-F)", + WAIT_BUDGET.as_millis() + ); + eprintln!("\n[openhuman] {held}\n"); + std::process::exit(0); + } + // Clamp the backoff to the remaining budget so the final sleep + // can't overshoot WAIT_BUDGET (the documented total-wait + // contract) by up to BACKOFF_CAP. + let remaining = WAIT_BUDGET.checked_sub(elapsed).unwrap_or_default(); + let delay = backoff_delay(attempt).min(remaining); + log::warn!( + "[cef-preflight] CEF cache held by another instance; waiting {} ms before re-check (elapsed {} ms, TAURI-RUST-F)", + delay.as_millis(), + elapsed.as_millis() + ); + std::thread::sleep(delay); + attempt = attempt.saturating_add(1); + } + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -237,6 +341,22 @@ mod tests { ); } + #[test] + fn backoff_is_exponential_then_capped() { + assert_eq!(backoff_delay(0), BACKOFF_BASE); // 100ms + assert_eq!(backoff_delay(1), Duration::from_millis(200)); + assert_eq!(backoff_delay(2), Duration::from_millis(400)); + // 800ms would exceed the cap → clamped. + assert_eq!(backoff_delay(3), BACKOFF_CAP); // 500ms + assert_eq!(backoff_delay(10), BACKOFF_CAP); + } + + #[test] + fn backoff_saturates_on_huge_attempt() { + // Must not panic/overflow on an absurd attempt count. + assert_eq!(backoff_delay(u32::MAX), BACKOFF_CAP); + } + #[test] fn parse_target_with_dashes_in_host() { assert_eq!( diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 5810f566f..a40242953 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -2239,16 +2239,18 @@ pub fn run() { deep_link_ipc::bind_and_listen() }; - // 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. + // CEF cache-lock preflight (macOS + Linux): if another OpenHuman instance + // holds the CEF user-data-dir SingletonLock, `cef_initialize` returns 0 and + // the vendored runtime used to panic (`left: 0, right: 1`). The common + // cause is a *sequential relaunch race* where the prior instance is still + // tearing down, so rather than exit on the first collision we wait + // (bounded, exponential backoff — the macOS/Linux analogue of the Windows + // pre-CEF wait) for the lock to clear, then proceed. If it is still held + // after the budget we exit cleanly (code 0). Stale locks (PID dead) are + // removed so crashed processes don't block launches. macOS: issue #864. + // Linux: OPENHUMAN-TAURI-K1. Sentry: TAURI-RUST-F. #[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); - } + cef_preflight::wait_for_cache_release(); let builder = { // Bypass macOS Keychain. Without this, every embedded service that diff --git a/app/src-tauri/vendor/tauri-cef b/app/src-tauri/vendor/tauri-cef index c90c8a330..5ec3d8836 160000 --- a/app/src-tauri/vendor/tauri-cef +++ b/app/src-tauri/vendor/tauri-cef @@ -1 +1 @@ -Subproject commit c90c8a330056286e7c0d05439ae3d4527fa4fafe +Subproject commit 5ec3d8836cbae300e36b7a9c0d302a65de92c58d