//! In-process core lifecycle. //! //! The core's HTTP/JSON-RPC server runs as a tokio task inside the Tauri host //! so its lifetime is tied to the GUI process — there is no sidecar to leak //! on Cmd+Q. //! //! Stale-listener policy (see issue #1130): if something is already listening //! on the configured port when `ensure_running` runs, we probe `GET /` to see //! whether it is an OpenHuman core. If it is, we treat it as a stale process //! left behind by a previous build/dev session and proactively terminate it //! (graceful signal, then a force-kill that *revalidates* the pid is still //! the same listener — guards against PID reuse if the original exits inside //! the grace window) before spawning a fresh embedded server — otherwise the //! new UI would silently bind to an older RPC implementation. If the listener //! is something else (or unreachable), we refuse to attach and surface the //! conflict so it can be diagnosed instead of producing 401s and version //! drift downstream. //! Set `OPENHUMAN_CORE_REUSE_EXISTING=1` to opt back into the legacy //! attach-to-whatever-is-listening behavior (e.g. a manual `openhuman-core //! run` harness for debugging). use std::sync::Arc; use std::sync::LazyLock; use parking_lot::RwLock; use tokio::net::TcpStream; use tokio::sync::oneshot::error::TryRecvError; use tokio::sync::Mutex; use tokio::task::JoinHandle; use tokio::time::{timeout, Duration}; use tokio_util::sync::CancellationToken; use crate::process_kill::{kill_pid_force, kill_pid_term}; const CORE_READY_POLL_MS: u64 = 100; // 60s ceiling. The embedded core boots in ~11s standalone, but inside the // desktop app it contends with CEF process startup (GPU/renderer/utility // re-execs) and can intermittently cross a 20s cap on a cold/loaded machine. // Polling returns the instant the core is ready, so a higher ceiling costs // nothing in the common fast case — it only avoids spurious startup-timeout // aborts under contention. const CORE_READY_ATTEMPTS: usize = 600; const CORE_READY_TIMEOUT_MS: u64 = CORE_READY_POLL_MS * CORE_READY_ATTEMPTS as u64; /// Generate a 256-bit cryptographically-random bearer token as a hex string. /// /// Uses the same encoding as `openhuman_core::core::auth::generate_token` /// (`hex::encode`) so the token format never silently diverges between the /// Tauri-side generator and the core-side validator. pub fn generate_rpc_token() -> String { use rand::RngCore as _; let mut bytes = [0u8; 32]; rand::rng().fill_bytes(&mut bytes); hex::encode(bytes) } static CURRENT_RPC_TOKEN: LazyLock>> = LazyLock::new(|| RwLock::new(None)); pub fn current_rpc_token() -> Option { CURRENT_RPC_TOKEN.read().clone() } #[derive(Debug, Clone, PartialEq, Eq)] pub struct PortFallbackNotice { pub preferred_port: u16, pub chosen_port: u16, } #[derive(Clone)] pub struct CoreProcessHandle { task: Arc>>>>, shutdown_token: Arc>, restart_lock: Arc>, preferred_port: u16, active_port: Arc>, last_port_fallback: Arc>>, /// Bearer token the embedded server validates on every inbound request. /// /// Handed to the embedded server **in-memory** (via the `rpc_token` /// argument of [`openhuman_core::core::jsonrpc::run_server_embedded_with_ready`]) /// rather than through `OPENHUMAN_CORE_TOKEN` on the process environment. /// Avoiding the env crossing keeps the bearer off `/proc//environ` /// (Linux) and out of `sysctl KERN_PROCARGS2` / `ps eww -p ` (macOS) /// where any same-UID process could otherwise read it without entitlement. /// The same value is exposed to the renderer via the `core_rpc_token` /// Tauri command so every RPC call can attach `Authorization: Bearer`. rpc_token: Arc, } impl CoreProcessHandle { pub fn new(port: u16) -> Self { // CURRENT_RPC_TOKEN is intentionally NOT set here. It is published by // ensure_running() only after the embedded server has been spawned // with this token handed over via the in-memory `rpc_token` arg of // `run_server_embedded_with_ready`. Setting it here would advertise // a token that an existing process listening on the port (the // harness-attach fast-path) has never seen, causing 401s on every // authenticated call. let rpc_token = generate_rpc_token(); Self { task: Arc::new(Mutex::new(None)), shutdown_token: Arc::new(Mutex::new(CancellationToken::new())), restart_lock: Arc::new(Mutex::new(())), preferred_port: port, active_port: Arc::new(RwLock::new(port)), last_port_fallback: Arc::new(RwLock::new(None)), rpc_token: Arc::new(rpc_token), } } /// The bearer token the embedded core validates on inbound RPC requests. pub fn rpc_token(&self) -> &str { &self.rpc_token } pub fn rpc_url(&self) -> String { format!("http://127.0.0.1:{}/rpc", self.port()) } pub fn port(&self) -> u16 { *self.active_port.read() } pub fn take_last_port_fallback_notice(&self) -> Option { self.last_port_fallback.write().take() } /// Acquire the restart lock to serialize overlapping restart requests. pub async fn restart_lock(&self) -> tokio::sync::MutexGuard<'_, ()> { self.restart_lock.lock().await } async fn is_rpc_port_open(&self) -> bool { is_port_open(self.port()).await } pub async fn ensure_running(&self) -> Result<(), String> { // Idempotent fast path: if we already spawned the embedded server in // *this* process and it's still alive on the port, the listener is // us — return Ok without identifying or taking over. Without this, // a second `start_core_process` call (e.g. HMR re-mounting the boot // gate) sees its own port as bound, classifies the listener as // "stale OpenHuman", and walks into the SIGTERM/SIGKILL takeover // path against itself. (#1130 takeover is meant to recover from // *external* leftover binaries, not our own in-process spawn.) { let guard = self.task.lock().await; if let Some(task) = guard.as_ref() { if !task.is_finished() && self.is_rpc_port_open().await { log::debug!( "[core] ensure_running: embedded task already running on port {} — no-op", self.port() ); return Ok(()); } } } if is_port_open(self.preferred_port).await { // Idempotent fast-path: if we already own a running embedded // task, the listener on this port is us — not a stale external // process. Without this short-circuit, a second `ensure_running` // call (from BootCheckGate re-render, React StrictMode mount, or // any double-invoke of `start_core_process`) hits the // `identify_listener` path, identifies the listener as // OpenHuman, calls `takeover_stale_listener`, and aborts with // "stale-listener pid matches the Tauri host pid; // refusing to self-terminate". (#1316 introduced the // frontend-driven `start_core_process` invoke without // hardening `ensure_running` against double-invoke.) { let guard = self.task.lock().await; if let Some(task) = guard.as_ref() { if !task.is_finished() { log::debug!( "[core] ensure_running: embedded task already running on port {}, returning Ok (idempotent)", self.port() ); return Ok(()); } } } if reuse_existing_listener_enabled() { log::warn!( "[core] OPENHUMAN_CORE_REUSE_EXISTING=1 — attaching to whatever is listening on port {} without identification (legacy behavior)", self.preferred_port ); return Ok(()); } match identify_listener(self.preferred_port).await { ListenerKind::OpenHuman => { log::warn!( "[core] found stale OpenHuman listener on port {} — taking over (issue #1130)", self.preferred_port ); self.takeover_stale_listener().await?; // Fall through to spawn-and-wait below. } ListenerKind::Unknown { reason } => { if is_expected_port_clash(&reason) { log::warn!( "[core] preferred RPC port {} is occupied by non-OpenHuman listener ({reason}); attempting fallback bind range", self.preferred_port ); } else { log::error!( "[core] preferred RPC port {} occupied by unexpected listener ({reason}); attempting fallback bind range", self.preferred_port ); } } } } for startup_attempt in 0..=1u8 { let mut retry_after_takeover = false; let shutdown_token = self.fresh_shutdown_token().await; let (ready_tx, mut ready_rx) = tokio::sync::oneshot::channel::< openhuman_core::core::jsonrpc::EmbeddedReadySignal, >(); let mut received_ready = false; { let mut guard = self.task.lock().await; if guard.is_none() { let port = self.preferred_port; // RPC bearer is handed to the embedded server in-memory // via the `rpc_token` argument of // run_server_embedded_with_ready (see below) — never // through OPENHUMAN_CORE_TOKEN on the process env. // Sidecar-era env-var transport was a leftover from the // PR #1061 cleanup; with the core in-process there is no // child process that needs the env crossing, and // sharing the bearer via env put it within reach of any // same-UID process that could read /proc//environ // (Linux) or sysctl KERN_PROCARGS2 / ps eww -p // (macOS). let token_for_core = self.rpc_token.clone(); // Surface the Tauri shell version to the in-process core so // backend-bound HTTP requests can attach `x-tauri-version` // analytics headers alongside `x-core-version`. std::env::set_var("OPENHUMAN_TAURI_VERSION", env!("CARGO_PKG_VERSION")); *self.active_port.write() = port; *self.last_port_fallback.write() = None; // Debug-build only: surface the RPC bearer token at a known // tmpdir path so the e2e test runner (a separate Node process) // can authenticate against the in-process core. Release builds // never write this file. The test harness reads it from // ${tmpdir}/openhuman-e2e-rpc-token. // // Token file is owner-read-write only (mode 0600) on Unix so a // shared dev box doesn't leak the bearer to other local users. #[cfg(debug_assertions)] { use std::io::Write as _; let token_path = std::env::temp_dir().join("openhuman-e2e-rpc-token"); let write_result = (|| -> std::io::Result<()> { let mut options = std::fs::OpenOptions::new(); options.create(true).write(true).truncate(true); #[cfg(unix)] { use std::os::unix::fs::OpenOptionsExt as _; options.mode(0o600); } let mut file = options.open(&token_path)?; file.write_all(self.rpc_token.as_bytes())?; file.sync_all()?; Ok(()) })(); if let Err(err) = write_result { log::warn!( "[core] failed to write e2e token file at {}: {err}", token_path.display() ); } else { log::debug!( "[core] wrote e2e token file at {} (debug build only)", token_path.display() ); } } log::info!( "[core] spawning embedded in-process core server on preferred port {port}" ); let task = tokio::spawn(async move { openhuman_core::core::jsonrpc::run_server_embedded_with_ready( None, Some(port), true, shutdown_token, ready_tx, // In-memory bearer handoff: the embedded server // seeds its auth subsystem from this value via // `auth::init_rpc_token_with_value`, so the token // never crosses OPENHUMAN_CORE_TOKEN on the // process env. Some(token_for_core), ) .await }); *guard = Some(task); // Publish only after the embedded server has been spawned // with the in-memory bearer in scope. Setting this earlier // would advertise a token to the frontend that the server // hadn't loaded yet. *CURRENT_RPC_TOKEN.write() = Some(self.rpc_token.to_string()); log::debug!("[auth] CURRENT_RPC_TOKEN set after embedded spawn"); } } // Readiness budget: 600 iterations x 100ms = 60s (CORE_READY_ATTEMPTS). // The embedded core's JSON-RPC controller registry has grown over // time and earlier 4s/10s/20s budgets started flaking under CI // worker load (issue: core_process tests intermittently failing with // "core process did not become ready"), especially under // cargo-llvm-cov instrumentation where the binary runs ~2x // slower, and under desktop CEF startup contention. 60s is still // well under any user-visible startup // expectation: in normal runs the ready signal arrives in well // under 1s and the loop exits immediately; the headroom only // matters on heavily loaded instrumented CI workers. for _ in 0..CORE_READY_ATTEMPTS { if !received_ready { match ready_rx.try_recv() { Ok(ready_signal) => { self.apply_embedded_ready_signal(ready_signal); received_ready = true; } Err(TryRecvError::Empty) => {} Err(TryRecvError::Closed) => {} } } if received_ready && self.is_rpc_port_open().await { log::info!("[core] core rpc became ready at {}", self.rpc_url()); return Ok(()); } let mut guard = self.task.lock().await; if let Some(task) = guard.as_ref() { if task.is_finished() { let task = guard.take().expect("checked is_some"); drop(guard); return match task.await { Ok(Ok(())) => { Err("in-process core server exited before becoming ready" .to_string()) } Ok(Err(err)) => { if let Some(openhuman_core::openhuman::connectivity::rpc::PickListenPortError::WouldTakeOver { preferred, .. }) = err .downcast_ref::() { if startup_attempt == 0 { log::warn!( "[core] preferred port {} requested stale-listener takeover from embedded bind path; retrying once", preferred ); self.takeover_stale_listener().await?; retry_after_takeover = true; break; } } Err(format!( "in-process core server exited before becoming ready: {err}" )) } Err(err) => Err(format!( "in-process core server task failed before ready: {err}" )), }; } } tokio::time::sleep(Duration::from_millis(CORE_READY_POLL_MS)).await; } if retry_after_takeover { continue; } // One last non-sleeping check avoids declaring a timeout when the // ready signal arrived during the final poll sleep. if !received_ready { if let Ok(ready_signal) = ready_rx.try_recv() { self.apply_embedded_ready_signal(ready_signal); received_ready = true; } } if received_ready && self.is_rpc_port_open().await { log::info!("[core] core rpc became ready at {}", self.rpc_url()); return Ok(()); } let port_open = self.is_rpc_port_open().await; return Err(self .cleanup_startup_timeout(received_ready, port_open, startup_attempt + 1) .await); } let port_open = self.is_rpc_port_open().await; Err(self.cleanup_startup_timeout(false, port_open, 2).await) } async fn cleanup_startup_timeout( &self, received_ready: bool, port_open: bool, attempt: u8, ) -> String { let port = self.port(); let task_state = { let guard = self.task.lock().await; match guard.as_ref() { None => "missing", Some(task) if task.is_finished() => "finished", Some(_) => "running", } }; log::error!( "[core] startup timed out after {CORE_READY_TIMEOUT_MS}ms \ (port={port}, ready_signal={received_ready}, port_open={port_open}, \ task_state={task_state}, attempt={attempt}); \ aborting embedded startup task before retry" ); self.cancel_shutdown_token(" after startup timeout").await; self.abort_task(" after startup timeout").await; format!( "core process did not become ready within {CORE_READY_TIMEOUT_MS}ms \ (port={port}, ready_signal={received_ready}, port_open={port_open}, \ task_state={task_state}, attempt={attempt})" ) } pub(crate) fn apply_embedded_ready_signal( &self, ready: openhuman_core::core::jsonrpc::EmbeddedReadySignal, ) { *self.active_port.write() = ready.port; std::env::set_var("OPENHUMAN_CORE_RPC_URL", self.rpc_url()); if let Some(preferred) = ready.fallback_from { let message = format!("port_fallback_engaged: {preferred} -> {}", ready.port); log::warn!("[core] {message}"); sentry::add_breadcrumb(sentry::Breadcrumb { category: Some("core.port".to_string()), level: sentry::Level::Warning, message: Some(message), ..Default::default() }); *self.last_port_fallback.write() = Some(PortFallbackNotice { preferred_port: preferred, chosen_port: ready.port, }); } else { *self.last_port_fallback.write() = None; } } /// Identify the OS pid currently bound to our port and terminate it, /// then wait for the port to free. Used when the listener has been /// fingerprinted as an OpenHuman core (via `GET /`) so killing it is safe. async fn takeover_stale_listener(&self) -> Result<(), String> { let port = self.preferred_port; let pid = match find_pid_on_port(port) { Some(pid) => pid, None => { return Err(format!( "could not determine pid bound to port {} — refusing to take over", port )); } }; let self_pid = std::process::id(); if pid == self_pid { // Defensive — `ensure_running` checks the port before spawning, // so this branch should be unreachable. If it ever hits, killing // ourselves would be catastrophic. return Err(format!( "stale-listener pid {pid} matches the Tauri host pid; refusing to self-terminate" )); } log::warn!( "[core] terminating stale OpenHuman process pid={pid} on port {} (issue #1130)", port ); if let Err(e) = kill_pid_term(pid) { return Err(format!("failed to signal stale openhuman pid {pid}: {e}")); } // Wait for the graceful exit, then revalidate ownership before any // force-kill — between the SIGTERM and a delayed SIGKILL the original // pid could have exited and been reused by an unrelated process. If // the port is now bound to a different pid (or to nothing), we do // NOT escalate to a force-kill against the originally-resolved pid. // (CodeRabbit feedback on #1166.) const GRACE_MS: u64 = 750; tokio::time::sleep(Duration::from_millis(GRACE_MS)).await; if is_port_open(port).await { match find_pid_on_port(port) { Some(current) if current == pid => { log::warn!( "[core] pid {pid} still bound to port {} after SIGTERM — escalating to SIGKILL", port ); if let Err(e) = kill_pid_force(pid) { return Err(format!( "failed to force-kill stale openhuman pid {pid}: {e}" )); } } Some(current) => { return Err(format!( "port {} rebounded to pid {current} after terminating pid {pid}; refusing to kill a different process", port )); } None => { // Port still showed open in `is_port_open` but pid lookup // returned nothing — likely a transient race with the // listener tearing down. Fall through to the poll loop. } } } const POLL_MS: u64 = 100; const MAX_WAIT_MS: u64 = 5_000; let mut waited_ms: u64 = GRACE_MS; while is_port_open(port).await { if waited_ms >= MAX_WAIT_MS { return Err(format!( "signaled pid {pid} but port {} remained bound after {MAX_WAIT_MS}ms", port )); } tokio::time::sleep(Duration::from_millis(POLL_MS)).await; waited_ms += POLL_MS; } log::info!( "[core] stale listener cleared (pid={pid}, port={}) after {waited_ms}ms", port ); Ok(()) } /// Restart the embedded core to pick up updated macOS permission grants. /// /// macOS caches permission state per-process; restarting forces a fresh /// read. If something else is bound to the port (e.g. a manual /// `openhuman-core run` harness) we surface that instead of looping. /// /// Issue: pub async fn restart(&self) -> Result<(), String> { log::info!("[core] restarting embedded core server for permission refresh"); let had_managed_task = { let guard = self.task.lock().await; guard.is_some() }; self.shutdown().await; if !had_managed_task { log::debug!( "[core] restart: no managed embedded task was running; ensure_running will resolve ownership/fallback" ); } const POLL_MS: u64 = 50; const MAX_WAIT_MS: u64 = 10_000; let mut waited_ms: u64 = 0; while self.is_rpc_port_open().await { if waited_ms >= MAX_WAIT_MS { return Err(format!( "Core RPC port {} did not become free after stopping the embedded server.", self.port() )); } tokio::time::sleep(Duration::from_millis(POLL_MS)).await; waited_ms += POLL_MS; } let result = self.ensure_running().await; match &result { Ok(()) => log::info!("[core] restart: embedded core ready after restart"), Err(e) => log::error!("[core] restart: failed to restart embedded core: {e}"), } result } /// Lock the task slot, take its handle if any, and abort it. Shared by /// `shutdown` (cleanup-on-drop semantics) and `send_terminate_signal` /// (cooperative early teardown from `RunEvent::ExitRequested`). async fn abort_task(&self, log_context: &str) { let mut task_guard = self.task.lock().await; if let Some(task) = task_guard.take() { log::info!("[core] aborting embedded core server task{log_context}"); task.abort(); } } async fn fresh_shutdown_token(&self) -> CancellationToken { let mut guard = self.shutdown_token.lock().await; if guard.is_cancelled() { log::debug!("[core] resetting embedded core shutdown token for new spawn"); *guard = CancellationToken::new(); } guard.clone() } async fn cancel_shutdown_token(&self, log_context: &str) { let token = self.shutdown_token.lock().await.clone(); if token.is_cancelled() { log::debug!("[core] embedded core shutdown token already cancelled{log_context}"); } else { log::info!("[core] cancelling embedded core shutdown token{log_context}"); token.cancel(); } } #[cfg(test)] async fn shutdown_token_is_cancelled(&self) -> bool { self.shutdown_token.lock().await.is_cancelled() } /// Stop the embedded server task. Safe to call when nothing is running. pub async fn shutdown(&self) { self.cancel_shutdown_token("").await; let task = { let mut task_guard = self.task.lock().await; task_guard.take() }; let Some(mut task) = task else { return; }; match timeout(Duration::from_secs(5), &mut task).await { Ok(Ok(Ok(()))) => { log::info!("[core] embedded core server task stopped gracefully"); } Ok(Ok(Err(err))) => { log::warn!("[core] embedded core server task ended during shutdown: {err}"); } Ok(Err(err)) => { log::warn!("[core] embedded core server task join failed during shutdown: {err}"); } Err(_) => { log::warn!( "[core] graceful embedded core shutdown timed out; aborting server task" ); task.abort(); let _ = task.await; } } } /// Synchronous-friendly shutdown for `RunEvent::ExitRequested`. /// /// Aborts the embedded server task so any background tokio tasks the /// server spawned stop driving I/O before CEF's teardown runs. Cheap /// and non-blocking on the UI thread — `JoinHandle::abort` returns /// immediately. pub async fn send_terminate_signal(&self) { self.cancel_shutdown_token(" on app shutdown").await; self.abort_task(" on app shutdown").await; } } /// A non-OpenHuman process holding the core RPC port. Surfaced to the frontend /// so the user can see what to free, and so the consent-gated force-quit knows /// which pid the user agreed to terminate. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct PortOwner { pub pid: u32, pub name: String, } /// Result returned to the frontend after a port-conflict auto-recovery attempt. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct RecoveryOutcome { pub success: bool, pub message: String, pub new_port: Option, /// Set when recovery failed because a foreign process still holds the port. /// `None` on success or when the owner can't be identified (e.g. a kernel /// reservation), in which case the frontend offers guidance, not a kill. pub foreign_owner: Option, } impl CoreProcessHandle { /// Attempt to recover from a port conflict: reap stale OpenHuman processes, /// wait briefly for the port to free, then start the embedded core. /// /// Called from the `recover_port_conflict` Tauri command when the frontend's /// boot-check detects the core is unreachable due to a port conflict. pub async fn recover_port_conflict(&self) -> RecoveryOutcome { log::debug!( "[core_process] recover_port_conflict: starting recovery for port {}", self.preferred_port ); tokio::task::spawn_blocking(crate::process_recovery::reap_stale_openhuman_processes) .await .unwrap_or_else(|e| { log::warn!("[core_process] recover_port_conflict: reap task panicked: {e}") }); log::debug!("[core_process] recover_port_conflict: stale process reap complete"); // Give the OS time to release the port after process termination. tokio::time::sleep(Duration::from_millis(500)).await; log::debug!("[core_process] recover_port_conflict: post-reap wait complete"); match self.ensure_running().await { Ok(()) => { let new_port = self.port(); log::info!( "[core_process] recover_port_conflict: recovery succeeded, core on port {new_port}" ); RecoveryOutcome { success: true, message: format!("Core recovered on port {new_port}"), new_port: Some(new_port), foreign_owner: None, } } Err(err) => { // Reaping stale OpenHuman processes didn't free the port, so a // foreign process is holding it. Identify it (name + pid) so the // frontend can surface it and offer a consent-gated force-quit. let port = self.preferred_port; let foreign_owner = tokio::task::spawn_blocking(move || identify_port_owner(port)) .await .unwrap_or(None); log::warn!( "[core_process] recover_port_conflict: recovery failed: {err}; foreign_owner={foreign_owner:?}" ); RecoveryOutcome { success: false, message: format!("Recovery failed: {err}"), new_port: None, foreign_owner, } } } } /// Terminate the foreign process the user consented to (the pid surfaced in /// the prior [`RecoveryOutcome::foreign_owner`]), then restart the core. /// /// Re-validates that `expected_pid` still owns the port immediately before /// killing — closing the PID-reuse window and refusing if the owner changed, /// the port is already free, or the owner is OpenHuman's own process. pub async fn force_quit_port_owner(&self, expected_pid: u32) -> RecoveryOutcome { let port = self.preferred_port; let self_pid = std::process::id(); log::info!("[core_process] force_quit_port_owner: port {port} expected_pid {expected_pid}"); let current = tokio::task::spawn_blocking(move || find_pid_on_port(port)) .await .unwrap_or(None); let pid = match validate_kill_target(current, expected_pid, self_pid) { Ok(pid) => pid, Err(reason) => { log::warn!("[core_process] force_quit_port_owner: refused: {reason}"); return RecoveryOutcome { success: false, message: reason, new_port: None, foreign_owner: None, }; } }; // Best-effort: a TERM failure is non-fatal because the escalation below // re-validates ownership and force-kills if the process is still there. if let Err(err) = tokio::task::spawn_blocking(move || crate::process_kill::kill_pid_term(pid)) .await .unwrap_or_else(|e| Err(format!("SIGTERM task panicked: {e}"))) { log::debug!( "[core_process] force_quit_port_owner: SIGTERM pid {pid} (best-effort): {err}" ); } tokio::time::sleep(Duration::from_millis(500)).await; // Re-validate before escalating: a bare `== Some(pid)` would SIGKILL a // different process that reused the pid and rebound the port within the // grace window, and would skip the self/protected-pid guards. let still = tokio::task::spawn_blocking(move || find_pid_on_port(port)) .await .unwrap_or(None); if validate_kill_target(still, pid, self_pid).is_ok() { if let Err(err) = tokio::task::spawn_blocking(move || crate::process_kill::kill_pid_force(pid)) .await .unwrap_or_else(|e| Err(format!("force-kill task panicked: {e}"))) { log::warn!("[core_process] force_quit_port_owner: force-kill pid {pid}: {err}"); return RecoveryOutcome { success: false, message: format!("Could not terminate pid {pid}: {err}"), new_port: None, foreign_owner: None, }; } tokio::time::sleep(Duration::from_millis(500)).await; } match self.ensure_running().await { Ok(()) => { let new_port = self.port(); log::info!( "[core_process] force_quit_port_owner: core recovered on port {new_port}" ); RecoveryOutcome { success: true, message: format!("Core recovered on port {new_port}"), new_port: Some(new_port), foreign_owner: None, } } Err(err) => RecoveryOutcome { success: false, message: format!("Recovery failed after terminating pid {pid}: {err}"), new_port: None, foreign_owner: None, }, } } } pub fn default_core_port() -> u16 { std::env::var("OPENHUMAN_CORE_PORT") .ok() .and_then(|v| v.parse::().ok()) .unwrap_or(7788) } /// Whether `OPENHUMAN_CORE_REUSE_EXISTING` is set to a truthy value. Opts /// back into the pre-#1130 behavior of attaching to whatever is listening /// on the port without identification — useful for manual harnesses. pub(crate) fn reuse_existing_listener_enabled() -> bool { std::env::var("OPENHUMAN_CORE_REUSE_EXISTING") .map(|v| matches!(v.trim(), "1" | "true" | "TRUE" | "yes" | "YES")) .unwrap_or(false) } async fn is_port_open(port: u16) -> bool { matches!( timeout( Duration::from_millis(150), TcpStream::connect(("127.0.0.1", port)), ) .await, Ok(Ok(_)) ) } /// What is currently listening on the core RPC port. #[derive(Debug)] enum ListenerKind { /// `GET /` returned a JSON body with `"name": "openhuman"` — i.e. a /// stale OpenHuman core process from a previous build/session. OpenHuman, /// Either the listener didn't speak HTTP, didn't respond, or returned /// a body that doesn't identify as openhuman. Unknown { reason: String }, } /// Probe `GET http://127.0.0.1:/` to fingerprint the listener. /// Unauthenticated — the core's root handler does not require a token. async fn identify_listener(port: u16) -> ListenerKind { let url = format!("http://127.0.0.1:{port}/"); let client = match reqwest::Client::builder() .timeout(Duration::from_millis(750)) .build() { Ok(c) => c, Err(e) => { return ListenerKind::Unknown { reason: format!("reqwest client build failed: {e}"), }; } }; let resp = match client.get(&url).send().await { Ok(r) => r, Err(e) => { return ListenerKind::Unknown { reason: format!("probe GET / failed: {e}"), }; } }; if !resp.status().is_success() { return ListenerKind::Unknown { reason: format!("probe GET / returned status {}", resp.status()), }; } let body = match resp.text().await { Ok(b) => b, Err(e) => { return ListenerKind::Unknown { reason: format!("probe GET / body read failed: {e}"), }; } }; if is_openhuman_root_body(&body) { log::info!("[core] listener on port {port} identified as openhuman core"); ListenerKind::OpenHuman } else { let preview: String = body.chars().take(80).collect(); ListenerKind::Unknown { reason: format!("probe GET / body did not identify as openhuman ({preview:?})"), } } } /// Pure parse of the root-handler JSON. Public-by-test so the fingerprinting /// logic stays unit-testable without standing up an HTTP server. fn is_openhuman_root_body(body: &str) -> bool { let value: serde_json::Value = match serde_json::from_str(body) { Ok(v) => v, Err(_) => return false, }; value .get("name") .and_then(|v| v.as_str()) .map(|s| s == "openhuman") .unwrap_or(false) } /// Returns true when a port conflict is deterministic environment state, not /// a high-signal unknown squatter worth sending to Sentry at error level. fn is_expected_port_clash(reason: &str) -> bool { let reason = reason.to_ascii_lowercase(); reason.contains("error sending request for url") || reason.contains("connection refused") || reason.contains("returned status 404") || reason.contains("returned status 200") || reason.contains("body did not identify as openhuman") || reason.contains("already in use by another process") || reason.contains("os error 10013") || reason.contains("wsaeacces") } #[cfg(unix)] fn find_pid_on_port(port: u16) -> Option { // Single port-qualified selector: `-iTCP:` ANDs protocol+port. Two // separate `-i` flags (`-iTCP -i:`) are ORed by lsof and would match // any TCP listener — risking returning an unrelated process as the owner. let output = std::process::Command::new("lsof") .args(["-nP", &format!("-iTCP:{port}"), "-sTCP:LISTEN", "-t"]) .output() .ok()?; if !output.status.success() { return None; } parse_lsof_pid(&String::from_utf8_lossy(&output.stdout)) } #[cfg(windows)] fn find_pid_on_port(port: u16) -> Option { use std::os::windows::process::CommandExt; const CREATE_NO_WINDOW: u32 = 0x0800_0000; let output = std::process::Command::new("netstat") .args(["-ano", "-p", "TCP"]) .creation_flags(CREATE_NO_WINDOW) .output() .ok()?; if !output.status.success() { return None; } parse_netstat_pid(&String::from_utf8_lossy(&output.stdout), port) } /// Pure parse of `lsof -t` output (one pid per line; first wins). fn parse_lsof_pid(stdout: &str) -> Option { stdout .lines() .map(str::trim) .find(|l| !l.is_empty()) .and_then(|l| l.parse::().ok()) } /// Pure parse of `netstat -ano` output for a LISTENING entry on `port`. /// /// Skips kernel-protected PIDs 0 (System Idle Process) and 4 (NT Kernel) — /// `HTTP.sys` and kernel-mode socket reservations occasionally surface as /// LISTENING under PID 4 even though no user-mode owner exists. Killing /// those is impossible and would otherwise abort startup recovery; if the /// "owner" is the kernel, callers should fall back to a port reroute /// instead of trying to take over. #[allow(dead_code)] // exercised only on windows builds fn parse_netstat_pid(stdout: &str, port: u16) -> Option { let needle = format!(":{port}"); for line in stdout.lines() { let trimmed = line.trim(); if !trimmed.contains("LISTENING") { continue; } let parts: Vec<&str> = trimmed.split_whitespace().collect(); // Expected: ["TCP", "127.0.0.1:7788", "0.0.0.0:0", "LISTENING", "1234"] if parts.len() >= 5 && parts[1].ends_with(&needle) { if let Ok(pid) = parts[parts.len() - 1].parse::() { if pid == 0 || pid == 4 { log::warn!( "[core] netstat reports port {port} owned by protected windows pid {pid}; treating as no-owner" ); continue; } return Some(pid); } } } None } /// Identify the process currently holding `port`, resolving its pid (via /// [`find_pid_on_port`]) to a human-readable name. Returns `None` when the /// port is free or owned by an unidentifiable (e.g. kernel-reserved) holder. fn identify_port_owner(port: u16) -> Option { let pid = find_pid_on_port(port)?; // Empty when the name can't be resolved — the frontend already shows the // pid, so a `"PID "` fallback here would render as "PID n (PID n)". let name = process_name(pid).unwrap_or_default(); Some(PortOwner { pid, name }) } #[cfg(windows)] fn process_name(pid: u32) -> Option { use std::os::windows::process::CommandExt; const CREATE_NO_WINDOW: u32 = 0x0800_0000; let output = std::process::Command::new("tasklist") .args(["/FI", &format!("PID eq {pid}"), "/FO", "CSV", "/NH"]) .creation_flags(CREATE_NO_WINDOW) .output() .ok()?; if !output.status.success() { return None; } parse_tasklist_name(&String::from_utf8_lossy(&output.stdout)) } #[cfg(unix)] fn process_name(pid: u32) -> Option { let output = std::process::Command::new("ps") .args(["-p", &pid.to_string(), "-o", "comm="]) .output() .ok()?; if !output.status.success() { return None; } parse_ps_comm(&String::from_utf8_lossy(&output.stdout)) } /// Pure parse of `tasklist /FO CSV /NH`: the image name is the first quoted /// CSV field (`"chrome.exe","1234",...`). The "no tasks" sentinel and any /// non-CSV line yield `None`. #[allow(dead_code)] // exercised only on windows builds fn parse_tasklist_name(stdout: &str) -> Option { let line = stdout.lines().map(str::trim).find(|l| !l.is_empty())?; let name = line.strip_prefix('"')?.split('"').next()?.trim(); if name.is_empty() { None } else { Some(name.to_string()) } } /// Pure parse of `ps -p -o comm=`: a single line, reduced to its /// trailing path component so a full executable path collapses to a file name. #[allow(dead_code)] // exercised only on unix builds fn parse_ps_comm(stdout: &str) -> Option { let line = stdout.lines().map(str::trim).find(|l| !l.is_empty())?; let name = line.rsplit(['/', '\\']).next().unwrap_or(line).trim(); if name.is_empty() { None } else { Some(name.to_string()) } } /// Decide whether the consent-gated force-quit may proceed. `current_owner` is /// the pid `find_pid_on_port` reports right before the kill; `expected_pid` is /// the owner the user was shown and agreed to terminate. Returns the pid to /// kill, or the reason the kill must be refused. fn validate_kill_target( current_owner: Option, expected_pid: u32, self_pid: u32, ) -> Result { match current_owner { None => Err("the port is no longer held by any process".to_string()), Some(pid) if pid == self_pid => Err(format!( "refusing to terminate OpenHuman's own process (pid {pid})" )), // Defense-in-depth: low/kernel pids are never legitimate port owners and // must never be signalled, even if one slipped past `find_pid_on_port`. // 0 — `kill(0, sig)` broadcasts to the caller's whole process group on // Unix; 1 — init/launchd; 4 — Windows NT kernel. Some(pid) if pid <= 1 || pid == 4 => { Err(format!("refusing to terminate protected pid {pid}")) } Some(pid) if pid != expected_pid => Err(format!( "port owner changed (was pid {expected_pid}, now pid {pid}); not terminating" )), Some(pid) => Ok(pid), } } #[cfg(test)] #[path = "core_process_tests.rs"] mod tests;