diff --git a/.claude/memory.md b/.claude/memory.md index b7631efb1..684b1b235 100644 --- a/.claude/memory.md +++ b/.claude/memory.md @@ -257,3 +257,12 @@ Quick reference for anyone starting with Claude on this project. Updated by the ## Pre-existing Flaky Tests - **`composio::action_tool` and `agent::harness::session::turn` intermittent failures** — These tests fail randomly when run as part of the full suite (likely shared state or timing), but pass individually. Not related to security/policy changes. Do not treat as blockers for security-module PRs. + +## Port Conflict Recovery (Issue #2617) + +- **Port fallback already in `pick_listen_port`** — `src/openhuman/connectivity/rpc.rs` tries ports 7789–7798 when 7788 is busy. Gap was: frontend `getCoreRpcUrl()` cached the URL on first resolution so it never picked up the fallback port, and stale-process reaping was macOS-only. +- **`process_recovery.rs` is platform-gated** — `reap_stale_openhuman_processes` had only a macOS impl. Linux uses `/proc//cmdline`; Windows uses `wmic process get`. Tests for each platform's parsing logic live in the same file, following the existing macOS test pattern. +- **`recover_port_conflict` is a Tauri IPC command, not JSON-RPC** — Rust E2E test for port fallback lives in `tests/json_rpc_e2e.rs` and calls `pick_listen_port` directly: bind port 7788 with a `std::net::TcpListener` (std, not tokio) to simulate conflict, confirm fallback, then serve via `tokio::net::TcpListener::from_std(pick_result.listener.into_std())`. +- **`BootCheckTransport` is the right hook for frontend recovery** — `app/src/lib/bootCheck/index.ts` is the injection point for new recovery capabilities; don't add them directly to the BootCheck component. +- **i18n bootCheck keys live in `-3.ts` chunks** — New keys must be added to all 13 language files simultaneously (the `-3.ts` chunk for each language). +- **Workflow folder** — `workflow/` at repo root has 5 markdown files (00–05) defining the full PR workflow: pick issue → architectobot plan → user approval → codecrusher → architectobot verify → checks → memory-keeper → commit → push/PR. diff --git a/app/src-tauri/src/core_process.rs b/app/src-tauri/src/core_process.rs index ec030cc65..5b5ea94be 100644 --- a/app/src-tauri/src/core_process.rs +++ b/app/src-tauri/src/core_process.rs @@ -397,7 +397,7 @@ impl CoreProcessHandle { ) } - fn apply_embedded_ready_signal( + pub(crate) fn apply_embedded_ready_signal( &self, ready: openhuman_core::core::jsonrpc::EmbeddedReadySignal, ) { @@ -630,6 +630,61 @@ impl CoreProcessHandle { } } +/// 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, +} + +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), + } + } + Err(err) => { + log::warn!("[core_process] recover_port_conflict: recovery failed: {err}"); + RecoveryOutcome { + success: false, + message: format!("Recovery failed: {err}"), + new_port: None, + } + } + } + } +} + pub fn default_core_port() -> u16 { std::env::var("OPENHUMAN_CORE_PORT") .ok() diff --git a/app/src-tauri/src/core_process_tests.rs b/app/src-tauri/src/core_process_tests.rs index 45a956a79..aa7879644 100644 --- a/app/src-tauri/src/core_process_tests.rs +++ b/app/src-tauri/src/core_process_tests.rs @@ -1,6 +1,6 @@ use super::{ current_rpc_token, default_core_port, generate_rpc_token, is_expected_port_clash, - is_openhuman_root_body, parse_lsof_pid, parse_netstat_pid, CoreProcessHandle, + is_openhuman_root_body, parse_lsof_pid, parse_netstat_pid, CoreProcessHandle, RecoveryOutcome, }; use std::sync::{Mutex, MutexGuard, OnceLock}; @@ -571,3 +571,105 @@ fn startup_timeout_cleanup_aborts_task_and_clears_slot() { ); }); } + +// --------------------------------------------------------------------------- +// RecoveryOutcome serialization tests +// --------------------------------------------------------------------------- + +#[test] +fn recovery_outcome_serializes_correctly() { + let outcome = RecoveryOutcome { + success: true, + message: "Core recovered on port 7789".to_string(), + new_port: Some(7789), + }; + let json = serde_json::to_value(&outcome).expect("serialize"); + assert_eq!(json["success"], serde_json::json!(true)); + assert_eq!( + json["message"], + serde_json::json!("Core recovered on port 7789") + ); + assert_eq!(json["new_port"], serde_json::json!(7789)); +} + +#[test] +fn recovery_outcome_failure_serializes_with_null_port() { + let outcome = RecoveryOutcome { + success: false, + message: "Recovery failed: port still busy".to_string(), + new_port: None, + }; + let json = serde_json::to_value(&outcome).expect("serialize"); + assert_eq!(json["success"], serde_json::json!(false)); + assert!( + json["new_port"].is_null(), + "new_port should be null when None" + ); +} + +#[test] +fn recover_port_conflict_succeeds_when_port_is_free() { + let _env_lock = env_lock(); + let _unset = EnvGuard::unset("OPENHUMAN_CORE_REUSE_EXISTING"); + let rt = tokio::runtime::Runtime::new().expect("runtime"); + + let outcome = rt.block_on(async { + // Bind a port, then release it so it's free when recover_port_conflict runs. + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind"); + let port = listener.local_addr().expect("addr").port(); + drop(listener); + // Brief yield to let the OS fully release the port. + tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; + + let handle = CoreProcessHandle::new(port); + let outcome = handle.recover_port_conflict().await; + handle.shutdown().await; + outcome + }); + + assert!( + outcome.success, + "recovery should succeed when port is free: {}", + outcome.message + ); + assert!( + outcome.new_port.is_some(), + "new_port should be set on success" + ); +} + +#[test] +fn recover_port_conflict_handles_stale_listener() { + let _env_lock = env_lock(); + let _unset = EnvGuard::unset("OPENHUMAN_CORE_REUSE_EXISTING"); + let rt = tokio::runtime::Runtime::new().expect("runtime"); + + // Bind a port, attempt recovery — the recovery must still succeed because + // ensure_running's fallback range kicks in when the preferred port is busy. + let outcome = rt.block_on(async { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind"); + let port = listener.local_addr().expect("addr").port(); + + let handle = CoreProcessHandle::new(port); + let outcome = handle.recover_port_conflict().await; + handle.shutdown().await; + drop(listener); + outcome + }); + + // Recovery may succeed via port fallback even with the listener held. + // We only assert that the outcome is well-formed. + assert!( + !outcome.message.is_empty(), + "outcome message must always be populated" + ); + if outcome.success { + assert!(outcome.new_port.is_some()); + } else { + assert!(outcome.new_port.is_none()); + } +} diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 138862673..579ad169a 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -262,6 +262,27 @@ async fn restart_core_process( state.inner().restart().await } +/// Attempt to auto-recover from a port conflict by reaping stale OpenHuman +/// processes (cross-platform) and restarting the embedded core. +/// +/// Called by the BootCheckGate "Fix Automatically" button when the core is +/// unreachable due to a port conflict. +#[tauri::command] +async fn recover_port_conflict( + state: tauri::State<'_, core_process::CoreProcessHandle>, +) -> Result { + log::info!("[core] recover_port_conflict: command invoked from frontend"); + let _guard = state.inner().restart_lock().await; + log::debug!("[core] recover_port_conflict: acquired restart lock"); + let outcome = state.inner().recover_port_conflict().await; + log::debug!( + "[core] recover_port_conflict: result success={} message={}", + outcome.success, + outcome.message + ); + Ok(outcome) +} + /// Start the embedded core process on demand. /// /// Called by the BootCheckGate (Local mode) before the version check. The @@ -3182,6 +3203,7 @@ pub fn run() { download_app_update, install_app_update, restart_core_process, + recover_port_conflict, start_core_process, reset_local_data, app_quit, diff --git a/app/src-tauri/src/process_recovery.rs b/app/src-tauri/src/process_recovery.rs index 494884c62..6e4fa0013 100644 --- a/app/src-tauri/src/process_recovery.rs +++ b/app/src-tauri/src/process_recovery.rs @@ -1,5 +1,13 @@ //! Startup recovery for OpenHuman processes left behind by hard exits. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +pub(crate) struct ProcessInfo { + pub pid: u32, + pub ppid: u32, + pub argv0: String, + pub command: String, +} + #[cfg(target_os = "macos")] mod imp { use std::collections::{HashMap, HashSet}; @@ -7,21 +15,13 @@ mod imp { use std::path::{Path, PathBuf}; use std::time::Duration; - use serde::Serialize; - use crate::cef_preflight; use crate::core_process; use crate::process_kill::{kill_pid_force, kill_pid_term}; - const TERM_GRACE: Duration = Duration::from_millis(500); + pub(crate) use super::ProcessInfo; - #[derive(Debug, Clone, PartialEq, Eq, Serialize)] - pub(crate) struct ProcessInfo { - pub pid: u32, - pub ppid: u32, - pub argv0: String, - pub command: String, - } + const TERM_GRACE: Duration = Duration::from_millis(500); #[derive(Debug, Default, PartialEq, Eq)] struct ReapSummary { @@ -416,24 +416,479 @@ mod imp { } } +/// Linux implementation: use /proc//cmdline to enumerate openhuman-core processes. +#[cfg(target_os = "linux")] +mod linux_imp { + use crate::core_process; + use crate::process_kill::{kill_pid_force, kill_pid_term}; + use std::time::Duration; + + pub(crate) use super::ProcessInfo; + + const TERM_GRACE: Duration = Duration::from_millis(500); + + pub(crate) fn reap_stale_openhuman_processes() { + if core_process::reuse_existing_listener_enabled() { + log::info!( + "[startup-recovery] OPENHUMAN_CORE_REUSE_EXISTING=1; skipping stale process reap" + ); + return; + } + + let self_pid = std::process::id(); + log::debug!("[startup-recovery] linux: scanning /proc for stale OpenHuman processes (self_pid={self_pid})"); + + let stale = match enumerate_openhuman_processes() { + Ok(procs) => procs, + Err(err) => { + log::warn!("[startup-recovery] linux: failed to enumerate processes: {err}"); + return; + } + }; + + if stale.is_empty() { + log::info!("[startup-recovery] linux: no stale OpenHuman processes found"); + return; + } + + log::info!( + "[startup-recovery] linux: found {} stale OpenHuman process(es), sending SIGTERM", + stale.len() + ); + for proc in &stale { + match kill_pid_term(proc.pid) { + Ok(()) => log::warn!( + "[startup-recovery] linux: SIGTERM stale OpenHuman pid={} cmd={}", + proc.pid, + proc.argv0 + ), + Err(err) => log::warn!( + "[startup-recovery] linux: failed to SIGTERM pid={}: {err}", + proc.pid + ), + } + } + + std::thread::sleep(TERM_GRACE); + + let after_term = match enumerate_openhuman_processes() { + Ok(procs) => procs, + Err(err) => { + log::warn!("[startup-recovery] linux: failed to re-enumerate after SIGTERM: {err}"); + return; + } + }; + + let stale_pids: std::collections::HashSet = stale.iter().map(|p| p.pid).collect(); + let mut kill_count = 0usize; + for proc in &after_term { + if stale_pids.contains(&proc.pid) { + match kill_pid_force(proc.pid) { + Ok(()) => { + kill_count += 1; + log::warn!( + "[startup-recovery] linux: SIGKILL stale OpenHuman pid={} cmd={}", + proc.pid, + proc.argv0 + ); + } + Err(err) => log::warn!( + "[startup-recovery] linux: failed to SIGKILL pid={}: {err}", + proc.pid + ), + } + } + } + + log::info!( + "[startup-recovery] linux: reap complete term={} kill={} total={}", + stale.len(), + kill_count, + stale.len() + ); + } + + pub(crate) fn enumerate_openhuman_processes() -> Result, String> { + let self_pid = std::process::id(); + let mut results = Vec::new(); + + let proc_dir = std::fs::read_dir("/proc").map_err(|e| format!("read_dir /proc: {e}"))?; + + for entry in proc_dir.flatten() { + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + let pid: u32 = match name_str.parse() { + Ok(p) => p, + Err(_) => continue, + }; + if pid == self_pid { + continue; + } + + let cmdline_path = format!("/proc/{pid}/cmdline"); + let cmdline_bytes = match std::fs::read(&cmdline_path) { + Ok(b) => b, + Err(_) => continue, + }; + + // /proc//cmdline uses NUL bytes as argument separators. + let cmdline = cmdline_bytes + .split(|&b| b == 0) + .filter(|seg| !seg.is_empty()) + .map(|seg| String::from_utf8_lossy(seg).into_owned()) + .collect::>(); + + let argv0 = match cmdline.first() { + Some(a) => a.clone(), + None => continue, + }; + + if !is_openhuman_executable(&argv0) { + continue; + } + + let ppid = read_ppid(pid).unwrap_or(0); + let command = cmdline.join(" "); + + log::debug!( + "[startup-recovery] linux: found OpenHuman process pid={pid} argv0={argv0}" + ); + results.push(ProcessInfo { + pid, + ppid, + argv0, + command, + }); + } + + Ok(results) + } + + fn read_ppid(pid: u32) -> Option { + let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?; + // /proc//stat: "pid (comm) state ppid ..." + // The comm field can contain spaces and parens, find the closing ')' first. + let after_comm = stat.rfind(')')?; + let rest = stat[after_comm + 1..].trim_start(); + // rest: "state ppid ..." + let mut parts = rest.split_whitespace(); + let _state = parts.next()?; + parts.next()?.parse().ok() + } + + fn is_openhuman_executable(argv0: &str) -> bool { + let filename = std::path::Path::new(argv0) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(argv0); + let lower = filename.to_ascii_lowercase(); + lower == "openhuman-core" || lower == "openhuman" + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn is_openhuman_executable_matches_core_binary() { + assert!(is_openhuman_executable("/usr/local/bin/openhuman-core")); + assert!(is_openhuman_executable("openhuman-core")); + assert!(is_openhuman_executable("/opt/OpenHuman/openhuman-core")); + } + + #[test] + fn is_openhuman_executable_matches_app_binary() { + assert!(is_openhuman_executable("/opt/OpenHuman/OpenHuman")); + assert!(is_openhuman_executable("openhuman")); + } + + #[test] + fn is_openhuman_executable_rejects_unrelated() { + assert!(!is_openhuman_executable("bash")); + assert!(!is_openhuman_executable("/usr/bin/python3")); + assert!(!is_openhuman_executable("node")); + } + + #[test] + fn enumerate_openhuman_processes_returns_no_self() { + // Enumerate and confirm self is not in the result. + let self_pid = std::process::id(); + let result = enumerate_openhuman_processes().expect("enumerate"); + assert!( + result.iter().all(|p| p.pid != self_pid), + "self pid {self_pid} must not appear in enumerated list" + ); + } + } +} + +/// Windows implementation: use sysinfo to enumerate openhuman processes. +#[cfg(target_os = "windows")] +mod windows_imp { + use crate::core_process; + use crate::process_kill::{kill_pid_force, kill_pid_term}; + use std::time::Duration; + + pub(crate) use super::ProcessInfo; + + const TERM_GRACE: Duration = Duration::from_millis(500); + + pub(crate) fn reap_stale_openhuman_processes() { + if core_process::reuse_existing_listener_enabled() { + log::info!( + "[startup-recovery] OPENHUMAN_CORE_REUSE_EXISTING=1; skipping stale process reap" + ); + return; + } + + let self_pid = std::process::id(); + log::debug!( + "[startup-recovery] windows: scanning processes for stale OpenHuman (self_pid={self_pid})" + ); + + let stale = match enumerate_openhuman_processes() { + Ok(procs) => procs, + Err(err) => { + log::warn!("[startup-recovery] windows: failed to enumerate processes: {err}"); + return; + } + }; + + if stale.is_empty() { + log::info!("[startup-recovery] windows: no stale OpenHuman processes found"); + return; + } + + log::info!( + "[startup-recovery] windows: found {} stale OpenHuman process(es), sending terminate", + stale.len() + ); + for proc in &stale { + match kill_pid_term(proc.pid) { + Ok(()) => log::warn!( + "[startup-recovery] windows: TERM stale OpenHuman pid={} exe={}", + proc.pid, + proc.argv0 + ), + Err(err) => log::warn!( + "[startup-recovery] windows: failed to terminate pid={}: {err}", + proc.pid + ), + } + } + + std::thread::sleep(TERM_GRACE); + + let after_term = match enumerate_openhuman_processes() { + Ok(procs) => procs, + Err(err) => { + log::warn!( + "[startup-recovery] windows: failed to re-enumerate after terminate: {err}" + ); + return; + } + }; + + let stale_pids: std::collections::HashSet = stale.iter().map(|p| p.pid).collect(); + let mut kill_count = 0usize; + for proc in &after_term { + if stale_pids.contains(&proc.pid) { + match kill_pid_force(proc.pid) { + Ok(()) => { + kill_count += 1; + log::warn!( + "[startup-recovery] windows: force-killed stale OpenHuman pid={} exe={}", + proc.pid, + proc.argv0 + ); + } + Err(err) => log::warn!( + "[startup-recovery] windows: failed to force-kill pid={}: {err}", + proc.pid + ), + } + } + } + + log::info!( + "[startup-recovery] windows: reap complete term={} kill={} total={}", + stale.len(), + kill_count, + stale.len() + ); + } + + pub(crate) fn enumerate_openhuman_processes() -> Result, String> { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + + let self_pid = std::process::id(); + + // Use WMIC to enumerate processes with their parent PIDs and executable paths. + // Output format: Caption,ProcessId,ParentProcessId,ExecutablePath + let output = std::process::Command::new("wmic") + .args([ + "process", + "get", + "Caption,ProcessId,ParentProcessId,ExecutablePath", + "/format:csv", + ]) + .creation_flags(CREATE_NO_WINDOW) + .output() + .map_err(|e| format!("spawn wmic: {e}"))?; + + if !output.status.success() { + return Err(format!("wmic exited with {}", output.status)); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + Ok(parse_wmic_output(&stdout, self_pid)) + } + + fn parse_wmic_output(stdout: &str, self_pid: u32) -> Vec { + let mut results = Vec::new(); + let mut lines = stdout.lines(); + + // Skip header lines until we find the CSV header row. + let header = loop { + match lines.next() { + Some(line) if line.trim().starts_with("Node,") => break line, + Some(_) => continue, + None => return results, + } + }; + + // Find column indices from the header. + let cols: Vec<&str> = header.split(',').collect(); + let idx_caption = cols.iter().position(|c| c.trim() == "Caption"); + let idx_pid = cols.iter().position(|c| c.trim() == "ProcessId"); + let idx_ppid = cols.iter().position(|c| c.trim() == "ParentProcessId"); + let idx_exe = cols.iter().position(|c| c.trim() == "ExecutablePath"); + + let (Some(idx_caption), Some(idx_pid), Some(idx_ppid), Some(idx_exe)) = + (idx_caption, idx_pid, idx_ppid, idx_exe) + else { + log::warn!("[startup-recovery] windows: wmic CSV header missing expected columns"); + return results; + }; + + for line in lines { + let line = line.trim(); + if line.is_empty() { + continue; + } + let fields: Vec<&str> = line.splitn(cols.len(), ',').collect(); + if fields.len() < cols.len() { + continue; + } + + let caption = fields[idx_caption].trim(); + let exe_path = fields[idx_exe].trim(); + let pid: u32 = match fields[idx_pid].trim().parse() { + Ok(p) => p, + Err(_) => continue, + }; + let ppid: u32 = fields[idx_ppid].trim().parse().unwrap_or(0); + + if pid == self_pid { + continue; + } + + let argv0 = if !exe_path.is_empty() { + exe_path.to_string() + } else { + caption.to_string() + }; + + if !is_openhuman_executable(caption, exe_path) { + continue; + } + + log::debug!( + "[startup-recovery] windows: found OpenHuman process pid={pid} argv0={argv0}" + ); + results.push(ProcessInfo { + pid, + ppid, + argv0: argv0.clone(), + command: argv0, + }); + } + + results + } + + fn is_openhuman_executable(caption: &str, exe_path: &str) -> bool { + let caption_lower = caption.to_ascii_lowercase(); + let exe_filename = std::path::Path::new(exe_path) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(exe_path) + .to_ascii_lowercase(); + caption_lower == "openhuman-core.exe" + || caption_lower == "openhuman.exe" + || exe_filename == "openhuman-core.exe" + || exe_filename == "openhuman.exe" + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn parse_wmic_output_finds_openhuman_processes() { + let csv = "\ +Node,Caption,ExecutablePath,ParentProcessId,ProcessId\r\n\ +\r\n\ +DESKTOP-ABC,openhuman-core.exe,C:\\Program Files\\OpenHuman\\openhuman-core.exe,1234,5678\r\n\ +DESKTOP-ABC,chrome.exe,C:\\Program Files\\Google\\Chrome\\chrome.exe,1,9000\r\n\ +"; + let results = parse_wmic_output(csv, 9999); + assert_eq!(results.len(), 1); + assert_eq!(results[0].pid, 5678); + assert_eq!(results[0].ppid, 1234); + assert!(results[0].argv0.contains("openhuman-core")); + } + + #[test] + fn parse_wmic_output_excludes_self_pid() { + let csv = "\ +Node,Caption,ExecutablePath,ParentProcessId,ProcessId\r\n\ +\r\n\ +DESKTOP-ABC,openhuman-core.exe,C:\\Program Files\\OpenHuman\\openhuman-core.exe,1,1234\r\n\ +"; + let results = parse_wmic_output(csv, 1234); + assert!(results.is_empty(), "self pid should be excluded"); + } + + #[test] + fn is_openhuman_executable_matches_core() { + assert!(is_openhuman_executable( + "openhuman-core.exe", + "C:\\path\\openhuman-core.exe" + )); + assert!(is_openhuman_executable( + "OpenHuman.exe", + "C:\\path\\OpenHuman.exe" + )); + } + + #[test] + fn is_openhuman_executable_rejects_unrelated() { + assert!(!is_openhuman_executable( + "chrome.exe", + "C:\\Chrome\\chrome.exe" + )); + assert!(!is_openhuman_executable("python.exe", "C:\\python.exe")); + } + } +} + #[cfg(target_os = "macos")] -pub(crate) use imp::{enumerate_openhuman_processes, reap_stale_openhuman_processes, ProcessInfo}; +pub(crate) use imp::{enumerate_openhuman_processes, reap_stale_openhuman_processes}; -#[cfg(not(target_os = "macos"))] -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] -pub(crate) struct ProcessInfo { - pub pid: u32, - pub ppid: u32, - pub argv0: String, - pub command: String, -} +#[cfg(target_os = "linux")] +pub(crate) use linux_imp::{enumerate_openhuman_processes, reap_stale_openhuman_processes}; -#[cfg(not(target_os = "macos"))] -pub(crate) fn reap_stale_openhuman_processes() { - log::debug!("[startup-recovery] skipped on non-macos platform"); -} - -#[cfg(not(target_os = "macos"))] -pub(crate) fn enumerate_openhuman_processes() -> Result, String> { - Ok(Vec::new()) -} +#[cfg(target_os = "windows")] +pub(crate) use windows_imp::{enumerate_openhuman_processes, reap_stale_openhuman_processes}; diff --git a/app/src/components/BootCheckGate/BootCheckGate.tsx b/app/src/components/BootCheckGate/BootCheckGate.tsx index d2c4d3fc7..7de91386d 100644 --- a/app/src/components/BootCheckGate/BootCheckGate.tsx +++ b/app/src/components/BootCheckGate/BootCheckGate.tsx @@ -14,7 +14,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { type BootCheckResult, runBootCheck } from '../../lib/bootCheck'; import { useT } from '../../lib/i18n/I18nContext'; -import { bootCheckTransport } from '../../services/bootCheckService'; +import { bootCheckTransport, recoverPortConflict } from '../../services/bootCheckService'; import { clearCoreRpcTokenCache, clearCoreRpcUrlCache, @@ -407,16 +407,31 @@ function ResultScreen({ if (result.kind === 'match') return null; if (result.kind === 'unreachable') { + const isPortConflict = result.portConflict === true; return (

- {t('bootCheck.cannotReach')} + {isPortConflict ? t('bootCheck.portConflictTitle') : t('bootCheck.cannotReach')}

- {result.reason || t('bootCheck.cannotReachDesc')} + {isPortConflict + ? t('bootCheck.portConflictBody') + : result.reason || t('bootCheck.cannotReachDesc')}

{actionError &&

{actionError}

} -
+
+ {isPortConflict && ( + + )}
@@ -728,6 +745,18 @@ export default function BootCheckGate({ children }: BootCheckGateProps) { log('[boot-check] gate — triggering cloud core update'); await transport.callRpc('openhuman.update_run', {}); log('[boot-check] gate — cloud core update triggered'); + } else if (result.kind === 'unreachable' && result.portConflict) { + log('[boot-check-gate] port conflict — invoking recover_port_conflict'); + const recovery = await recoverPortConflict(); + log( + '[boot-check-gate] recovery result: success=%s message=%s', + recovery.success, + recovery.message + ); + if (!recovery.success) { + setActionError(t('bootCheck.portConflictFixFailed')); + return; + } } // Re-run the full check after the action. diff --git a/app/src/components/BootCheckGate/__tests__/BootCheckGate.test.tsx b/app/src/components/BootCheckGate/__tests__/BootCheckGate.test.tsx index 973e71806..f2dac9fab 100644 --- a/app/src/components/BootCheckGate/__tests__/BootCheckGate.test.tsx +++ b/app/src/components/BootCheckGate/__tests__/BootCheckGate.test.tsx @@ -32,6 +32,15 @@ vi.mock('../../../lib/bootCheck', () => ({ runBootCheck: (...args: unknown[]) => mockRunBootCheck(...args), })); +const mockRecoverPortConflict = vi.fn(); +vi.mock('../../../services/bootCheckService', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + recoverPortConflict: (...args: unknown[]) => mockRecoverPortConflict(...args), + }; +}); + const mockTestCoreRpcConnection = vi.fn(); vi.mock('../../../services/coreRpcClient', () => ({ callCoreRpc: vi.fn(), @@ -572,6 +581,125 @@ describe('BootCheckGate — pre-set mode (subsequent launches)', () => { }); }); +describe('BootCheckGate — port conflict recovery', () => { + beforeEach(() => { + mockRecoverPortConflict.mockReset(); + mockRunBootCheck.mockReset(); + }); + + it('shows "Fix Automatically" button when portConflict=true', async () => { + mockRunBootCheck.mockResolvedValue({ + kind: 'unreachable', + reason: 'port conflict', + portConflict: true, + }); + + renderGate(); + fireEvent.click(screen.getByRole('button', { name: 'Continue' })); + + await waitFor(() => { + expect(screen.getByTestId('fix-automatically-btn')).toBeInTheDocument(); + }); + expect(screen.getByTestId('fix-automatically-btn').textContent).toBe('Fix Automatically'); + }); + + it('does not show "Fix Automatically" button when portConflict is not set', async () => { + mockRunBootCheck.mockResolvedValue({ kind: 'unreachable', reason: 'some other error' }); + + renderGate(); + fireEvent.click(screen.getByRole('button', { name: 'Continue' })); + + await waitFor(() => { + expect(screen.getByText("Can't Reach the Runtime")).toBeInTheDocument(); + }); + expect(screen.queryByTestId('fix-automatically-btn')).not.toBeInTheDocument(); + }); + + it('calls recoverPortConflict when "Fix Automatically" is clicked', async () => { + mockRunBootCheck + .mockResolvedValueOnce({ kind: 'unreachable', reason: 'port conflict', portConflict: true }) + .mockResolvedValue({ kind: 'match' }); + mockRecoverPortConflict.mockResolvedValue({ success: true, message: 'ok', new_port: 7789 }); + + renderGate(); + fireEvent.click(screen.getByRole('button', { name: 'Continue' })); + + await waitFor(() => { + expect(screen.getByTestId('fix-automatically-btn')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTestId('fix-automatically-btn')); + + await waitFor(() => { + expect(mockRecoverPortConflict).toHaveBeenCalled(); + }); + }); + + it('re-runs boot check after successful recovery', async () => { + mockRunBootCheck + .mockResolvedValueOnce({ kind: 'unreachable', reason: 'port conflict', portConflict: true }) + .mockResolvedValue({ kind: 'match' }); + mockRecoverPortConflict.mockResolvedValue({ success: true, message: 'ok', new_port: 7789 }); + + renderGate(); + fireEvent.click(screen.getByRole('button', { name: 'Continue' })); + + await waitFor(() => { + expect(screen.getByTestId('fix-automatically-btn')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTestId('fix-automatically-btn')); + + await waitFor(() => { + expect(screen.getByTestId('app-content')).toBeInTheDocument(); + }); + expect(mockRunBootCheck).toHaveBeenCalledTimes(2); + }); + + it('shows portConflictFixFailed message when recovery fails', async () => { + mockRunBootCheck.mockResolvedValue({ + kind: 'unreachable', + reason: 'port conflict', + portConflict: true, + }); + mockRecoverPortConflict.mockResolvedValue({ + success: false, + message: 'still busy', + new_port: undefined, + }); + + renderGate(); + fireEvent.click(screen.getByRole('button', { name: 'Continue' })); + + await waitFor(() => { + expect(screen.getByTestId('fix-automatically-btn')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTestId('fix-automatically-btn')); + + await waitFor(() => { + expect( + screen.getByText("Automatic fix didn't work. Please restart your computer and try again.") + ).toBeInTheDocument(); + }); + }); + + it('"Pick a Different Runtime" still renders as secondary for port conflict', async () => { + mockRunBootCheck.mockResolvedValue({ + kind: 'unreachable', + reason: 'port conflict', + portConflict: true, + }); + + renderGate(); + fireEvent.click(screen.getByRole('button', { name: 'Continue' })); + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Pick a Different Runtime' })).toBeInTheDocument(); + }); + }); +}); + describe('BootCheckGate — picker (web build, !isTauri)', () => { beforeEach(() => { mockedIsTauri.mockReturnValue(false); diff --git a/app/src/lib/bootCheck/index.test.ts b/app/src/lib/bootCheck/index.test.ts index 161aeffa6..e071d09ee 100644 --- a/app/src/lib/bootCheck/index.test.ts +++ b/app/src/lib/bootCheck/index.test.ts @@ -143,6 +143,11 @@ describe('runBootCheck — local mode', () => { vi.useRealTimers(); expect(result.kind).toBe('unreachable'); + // start_core_process succeeded (invokeCmd resolves) so portConflict must NOT be set — + // the timeout alone is not evidence of a port conflict. + if (result.kind === 'unreachable') { + expect(result.portConflict).toBeFalsy(); + } }); }); @@ -214,6 +219,87 @@ describe('runBootCheck — unset mode', () => { }); }); +// --------------------------------------------------------------------------- +// Port conflict auto-recovery tests +// --------------------------------------------------------------------------- + +describe('runBootCheck — port conflict auto-recovery', () => { + it('auto-recovery succeeds: start fails, recovery succeeds, second start succeeds', async () => { + const appVersion = (await import('../../utils/config')).APP_VERSION; + + let startCallCount = 0; + const transport: BootCheckTransport = { + callRpc: rpcResponder({ + 'core.ping': {}, + 'openhuman.service_status': { installed: false, running: false }, + 'openhuman.update_version': { result: { version: appVersion } }, + }), + invokeCmd: vi.fn(async (cmd: string) => { + if (cmd === 'start_core_process') { + startCallCount += 1; + if (startCallCount === 1) throw new Error('port in use'); + return undefined; + } + return undefined; + }) as BootCheckTransport['invokeCmd'], + recoverPortConflict: vi + .fn() + .mockResolvedValue({ success: true, message: 'recovered', new_port: 7789 }), + }; + + const result = await runBootCheck({ kind: 'local' }, transport); + expect(result.kind).toBe('match'); + expect(transport.recoverPortConflict).toHaveBeenCalled(); + }); + + it('returns unreachable with portConflict=true when both start and recovery fail', async () => { + const transport: BootCheckTransport = { + callRpc: vi.fn(), + invokeCmd: vi.fn().mockRejectedValue(new Error('port in use')), + recoverPortConflict: vi + .fn() + .mockResolvedValue({ success: false, message: 'port still busy', new_port: undefined }), + }; + + const result = await runBootCheck({ kind: 'local' }, transport); + expect(result.kind).toBe('unreachable'); + if (result.kind === 'unreachable') { + expect(result.portConflict).toBe(true); + } + }); + + it('clears RPC URL cache and retries waitForCore on timeout', async () => { + const appVersion = (await import('../../utils/config')).APP_VERSION; + + let pingCallCount = 0; + const transport: BootCheckTransport = { + invokeCmd: vi.fn().mockResolvedValue(undefined), + callRpc: vi.fn(async (method: string) => { + if (method === 'core.ping') { + pingCallCount += 1; + // waitForCore(10_000) makes ~12 attempts with 200→1000ms exponential backoff. + // Fail exactly those 12 so the initial call times out; ping 13 succeeds so + // the cache-clear retry waitForCore(5_000) returns true on its first attempt. + if (pingCallCount <= 12) throw new Error('timeout'); + return {}; + } + if (method === 'openhuman.service_status') return { installed: false, running: false }; + if (method === 'openhuman.update_version') return { result: { version: appVersion } }; + throw new Error(`Unexpected RPC: ${method}`); + }) as BootCheckTransport['callRpc'], + }; + + vi.useFakeTimers(); + const promise = runBootCheck({ kind: 'local' }, transport); + await vi.runAllTimersAsync(); + const result = await promise; + vi.useRealTimers(); + + // Initial waitForCore timed out → cache cleared → second waitForCore succeeded. + expect(result.kind).toBe('match'); + }); +}); + // --------------------------------------------------------------------------- // Edge-case branches surfaced by the diff-coverage gate // --------------------------------------------------------------------------- diff --git a/app/src/lib/bootCheck/index.ts b/app/src/lib/bootCheck/index.ts index fd553279e..6a4b13a52 100644 --- a/app/src/lib/bootCheck/index.ts +++ b/app/src/lib/bootCheck/index.ts @@ -31,7 +31,7 @@ export type BootCheckResult = | { kind: 'outdatedLocal' } | { kind: 'outdatedCloud' } | { kind: 'noVersionMethod' } - | { kind: 'unreachable'; reason: string }; + | { kind: 'unreachable'; reason: string; portConflict?: boolean }; // --------------------------------------------------------------------------- // Transport interface (injectable for tests) @@ -42,6 +42,8 @@ export interface BootCheckTransport { callRpc: (method: string, params?: Record) => Promise; /** Invoke a Tauri command. */ invokeCmd: (cmd: string, args?: Record) => Promise; + /** Attempt to auto-recover from a port conflict. Optional — only wired in desktop builds. */ + recoverPortConflict?: () => Promise<{ success: boolean; message: string; new_port?: number }>; } // The production transport lives in `app/src/services/bootCheckService.ts` @@ -201,22 +203,62 @@ export async function runBootCheck( if (mode.kind === 'local') { log('[boot-check] local mode — starting core process'); + let startFailed = false; try { await invokeCmd('start_core_process', {}); log('[boot-check] start_core_process invoked successfully'); } catch (err) { + startFailed = true; logError('[boot-check] start_core_process failed: %o', err); - return { - kind: 'unreachable', - reason: `Failed to start local core: ${err instanceof Error ? err.message : String(err)}`, - }; + } + + // If start failed, attempt port-conflict auto-recovery before giving up. + if (startFailed && transport.recoverPortConflict) { + log('[boot-check] start_core_process failed — attempting port-conflict auto-recovery'); + try { + const recovery = await transport.recoverPortConflict(); + log( + '[boot-check] port-conflict recovery result: success=%s message=%s', + recovery.success, + recovery.message + ); + if (!recovery.success) { + logError('[boot-check] port-conflict recovery failed: %s', recovery.message); + return { + kind: 'unreachable', + reason: `Failed to start local core — port conflict recovery failed: ${recovery.message}`, + portConflict: true, + }; + } + // Recovery succeeded — clear the URL cache so we pick up the new port. + clearCoreRpcUrlCache(); + log('[boot-check] port-conflict recovery succeeded, RPC URL cache cleared'); + } catch (recoveryErr) { + logError('[boot-check] port-conflict recovery threw: %o', recoveryErr); + return { + kind: 'unreachable', + reason: `Failed to start local core — recovery error: ${recoveryErr instanceof Error ? recoveryErr.message : String(recoveryErr)}`, + portConflict: true, + }; + } + } else if (startFailed) { + return { kind: 'unreachable', reason: `Failed to start local core` }; } // Wait for the embedded core to be reachable. - const reachable = await waitForCore(callRpc); + let reachable = await waitForCore(callRpc); if (!reachable) { - logError('[boot-check] local core unreachable after retries'); - return { kind: 'unreachable', reason: 'Local core did not respond in time' }; + logError('[boot-check] local core unreachable after retries — trying cache clear + retry'); + clearCoreRpcUrlCache(); + reachable = await waitForCore(callRpc, 5_000); + } + if (!reachable) { + logError('[boot-check] local core unreachable after retries and cache clear'); + return { + kind: 'unreachable', + reason: 'Local core did not respond in time', + portConflict: startFailed, + }; } // Check for a legacy background daemon that should be removed. diff --git a/app/src/lib/i18n/chunks/ar-3.ts b/app/src/lib/i18n/chunks/ar-3.ts index 1f8c82830..2043875c3 100644 --- a/app/src/lib/i18n/chunks/ar-3.ts +++ b/app/src/lib/i18n/chunks/ar-3.ts @@ -185,6 +185,13 @@ const ar3: TranslationMap = { 'bootCheck.restartUpdateCore': 'إعادة تشغيل / تحديث بيئة التشغيل', 'bootCheck.unexpectedError': 'خطأ غير متوقع في فحص بدء التشغيل', 'bootCheck.actionFailed': 'حدث خطأ ما. يرجى المحاولة مرة أخرى.', + 'bootCheck.portConflictTitle': 'تعذّر تشغيل محرّك التطبيق', + 'bootCheck.portConflictBody': + 'هناك عملية أخرى تستخدم منفذ الشبكة الذي يحتاجه OpenHuman. سنحاول إصلاح ذلك تلقائيًا.', + 'bootCheck.portConflictFixButton': 'إصلاح تلقائي', + 'bootCheck.portConflictFixing': 'جارٍ الإصلاح…', + 'bootCheck.portConflictFixFailed': + 'لم ينجح الإصلاح التلقائي. يُرجى إعادة تشغيل الكمبيوتر والمحاولة مجددًا.', 'notifications.justNow': 'الآن', 'notifications.minAgo': 'منذ {n} د', 'notifications.hrAgo': 'منذ {n} س', diff --git a/app/src/lib/i18n/chunks/bn-3.ts b/app/src/lib/i18n/chunks/bn-3.ts index 1c1e78749..0b6fa015e 100644 --- a/app/src/lib/i18n/chunks/bn-3.ts +++ b/app/src/lib/i18n/chunks/bn-3.ts @@ -190,6 +190,13 @@ const bn3: TranslationMap = { 'bootCheck.restartUpdateCore': 'রানটাইম রিস্টার্ট / আপডেট করুন', 'bootCheck.unexpectedError': 'অপ্রত্যাশিত বুট-চেক ত্রুটি', 'bootCheck.actionFailed': 'কিছু একটা ভুল হয়েছে। আবার চেষ্টা করুন।', + 'bootCheck.portConflictTitle': 'অ্যাপ ইঞ্জিন চালু করা যায়নি', + 'bootCheck.portConflictBody': + 'অন্য একটি প্রক্রিয়া OpenHuman-এর প্রয়োজনীয় নেটওয়ার্ক পোর্ট ব্যবহার করছে। আমরা স্বয়ংক্রিয়ভাবে এটি ঠিক করার চেষ্টা করব।', + 'bootCheck.portConflictFixButton': 'স্বয়ংক্রিয়ভাবে ঠিক করুন', + 'bootCheck.portConflictFixing': 'ঠিক করা হচ্ছে…', + 'bootCheck.portConflictFixFailed': + 'স্বয়ংক্রিয় সংশোধন কাজ করেনি। অনুগ্রহ করে আপনার কম্পিউটার পুনরায় চালু করুন এবং আবার চেষ্টা করুন।', 'notifications.justNow': 'এইমাত্র', 'notifications.minAgo': '{n} মিনিট আগে', 'notifications.hrAgo': '{n} ঘণ্টা আগে', diff --git a/app/src/lib/i18n/chunks/de-3.ts b/app/src/lib/i18n/chunks/de-3.ts index 9204d2ab6..095c92b0b 100644 --- a/app/src/lib/i18n/chunks/de-3.ts +++ b/app/src/lib/i18n/chunks/de-3.ts @@ -195,6 +195,13 @@ const de3: TranslationMap = { 'bootCheck.restartUpdateCore': 'Runtime neu starten/aktualisieren', 'bootCheck.unexpectedError': 'Unerwarteter Boot-Check-Fehler', 'bootCheck.actionFailed': 'Etwas ist schief gelaufen. Bitte versuche es erneut.', + 'bootCheck.portConflictTitle': 'App-Engine konnte nicht gestartet werden', + 'bootCheck.portConflictBody': + 'Ein anderer Prozess nutzt den Netzwerkport, den OpenHuman benötigt. Wir versuchen, das automatisch zu beheben.', + 'bootCheck.portConflictFixButton': 'Automatisch beheben', + 'bootCheck.portConflictFixing': 'Wird behoben…', + 'bootCheck.portConflictFixFailed': + 'Automatische Behebung fehlgeschlagen. Bitte starten Sie Ihren Computer neu und versuchen Sie es erneut.', 'notifications.justNow': 'gerade jetzt', 'notifications.minAgo': 'Vor {n}m', 'notifications.hrAgo': 'Vor {n}h', diff --git a/app/src/lib/i18n/chunks/en-3.ts b/app/src/lib/i18n/chunks/en-3.ts index 3d823902a..bf1bd3b9b 100644 --- a/app/src/lib/i18n/chunks/en-3.ts +++ b/app/src/lib/i18n/chunks/en-3.ts @@ -189,6 +189,13 @@ const en3: TranslationMap = { 'bootCheck.restartUpdateCore': 'Restart / Update Runtime', 'bootCheck.unexpectedError': 'Unexpected Boot-Check Error', 'bootCheck.actionFailed': 'Something went wrong. Please try again.', + 'bootCheck.portConflictTitle': "Couldn't Start the App Engine", + 'bootCheck.portConflictBody': + "Another process is using the network port OpenHuman needs. We'll try to fix this automatically.", + 'bootCheck.portConflictFixButton': 'Fix Automatically', + 'bootCheck.portConflictFixing': 'Fixing…', + 'bootCheck.portConflictFixFailed': + "Automatic fix didn't work. Please restart your computer and try again.", 'notifications.justNow': 'just now', 'notifications.minAgo': '{n}m ago', 'notifications.hrAgo': '{n}h ago', diff --git a/app/src/lib/i18n/chunks/es-3.ts b/app/src/lib/i18n/chunks/es-3.ts index 51fd119fd..c6ae35191 100644 --- a/app/src/lib/i18n/chunks/es-3.ts +++ b/app/src/lib/i18n/chunks/es-3.ts @@ -193,6 +193,13 @@ const es3: TranslationMap = { 'bootCheck.restartUpdateCore': 'Reiniciar / Actualizar runtime', 'bootCheck.unexpectedError': 'Error inesperado en verificación de arranque', 'bootCheck.actionFailed': 'Algo salió mal. Inténtalo de nuevo.', + 'bootCheck.portConflictTitle': 'No se pudo iniciar el motor de la aplicación', + 'bootCheck.portConflictBody': + 'Otro proceso está usando el puerto de red que OpenHuman necesita. Intentaremos solucionarlo automáticamente.', + 'bootCheck.portConflictFixButton': 'Corregir automáticamente', + 'bootCheck.portConflictFixing': 'Corrigiendo…', + 'bootCheck.portConflictFixFailed': + 'La corrección automática no funcionó. Reinicia tu equipo e inténtalo de nuevo.', 'notifications.justNow': 'justo ahora', 'notifications.minAgo': 'hace {n}m', 'notifications.hrAgo': 'hace {n}h', diff --git a/app/src/lib/i18n/chunks/fr-3.ts b/app/src/lib/i18n/chunks/fr-3.ts index b8cca5af2..2d5d36276 100644 --- a/app/src/lib/i18n/chunks/fr-3.ts +++ b/app/src/lib/i18n/chunks/fr-3.ts @@ -194,6 +194,13 @@ const fr3: TranslationMap = { 'bootCheck.restartUpdateCore': 'Redémarrer / Mettre à jour le runtime', 'bootCheck.unexpectedError': 'Erreur inattendue lors de la vérification au démarrage', 'bootCheck.actionFailed': "Une erreur s'est produite. Réessaie.", + 'bootCheck.portConflictTitle': "Impossible de démarrer le moteur de l'application", + 'bootCheck.portConflictBody': + 'Un autre processus utilise le port réseau dont OpenHuman a besoin. Nous allons tenter de corriger cela automatiquement.', + 'bootCheck.portConflictFixButton': 'Corriger automatiquement', + 'bootCheck.portConflictFixing': 'Correction en cours…', + 'bootCheck.portConflictFixFailed': + "La correction automatique n'a pas fonctionné. Veuillez redémarrer votre ordinateur et réessayer.", 'notifications.justNow': "à l'instant", 'notifications.minAgo': 'il y a {n} min', 'notifications.hrAgo': 'il y a {n} h', diff --git a/app/src/lib/i18n/chunks/hi-3.ts b/app/src/lib/i18n/chunks/hi-3.ts index eb6e8b32d..14f4d06f1 100644 --- a/app/src/lib/i18n/chunks/hi-3.ts +++ b/app/src/lib/i18n/chunks/hi-3.ts @@ -189,6 +189,13 @@ const hi3: TranslationMap = { 'bootCheck.restartUpdateCore': 'रनटाइम रीस्टार्ट / अपडेट करें', 'bootCheck.unexpectedError': 'अनपेक्षित बूट-चेक एरर', 'bootCheck.actionFailed': 'कुछ गड़बड़ हो गई। दोबारा कोशिश करें।', + 'bootCheck.portConflictTitle': 'ऐप इंजन शुरू नहीं हो सका', + 'bootCheck.portConflictBody': + 'कोई अन्य प्रक्रिया उस नेटवर्क पोर्ट का उपयोग कर रही है जो OpenHuman को चाहिए। हम इसे स्वचालित रूप से ठीक करने का प्रयास करेंगे।', + 'bootCheck.portConflictFixButton': 'स्वचालित रूप से ठीक करें', + 'bootCheck.portConflictFixing': 'ठीक हो रहा है…', + 'bootCheck.portConflictFixFailed': + 'स्वचालित सुधार काम नहीं आया। कृपया अपना कंप्यूटर पुनः आरंभ करें और पुनः प्रयास करें।', 'notifications.justNow': 'अभी-अभी', 'notifications.minAgo': '{n}मि. पहले', 'notifications.hrAgo': '{n}घं. पहले', diff --git a/app/src/lib/i18n/chunks/id-3.ts b/app/src/lib/i18n/chunks/id-3.ts index c3acfa6fb..91b1b485d 100644 --- a/app/src/lib/i18n/chunks/id-3.ts +++ b/app/src/lib/i18n/chunks/id-3.ts @@ -190,6 +190,13 @@ const id3: TranslationMap = { 'bootCheck.restartUpdateCore': 'Mulai Ulang / Perbarui Runtime', 'bootCheck.unexpectedError': 'Kesalahan Boot-Check Tak Terduga', 'bootCheck.actionFailed': 'Terjadi kesalahan. Silakan coba lagi.', + 'bootCheck.portConflictTitle': 'Tidak dapat memulai mesin aplikasi', + 'bootCheck.portConflictBody': + 'Proses lain sedang menggunakan port jaringan yang dibutuhkan OpenHuman. Kami akan mencoba memperbaikinya secara otomatis.', + 'bootCheck.portConflictFixButton': 'Perbaiki Otomatis', + 'bootCheck.portConflictFixing': 'Memperbaiki…', + 'bootCheck.portConflictFixFailed': + 'Perbaikan otomatis tidak berhasil. Silakan restart komputer Anda dan coba lagi.', 'notifications.justNow': 'baru saja', 'notifications.minAgo': '{n}m lalu', 'notifications.hrAgo': '{n}j lalu', diff --git a/app/src/lib/i18n/chunks/it-3.ts b/app/src/lib/i18n/chunks/it-3.ts index 4a3f41d0b..ed0f7cd95 100644 --- a/app/src/lib/i18n/chunks/it-3.ts +++ b/app/src/lib/i18n/chunks/it-3.ts @@ -193,6 +193,13 @@ const it3: TranslationMap = { 'bootCheck.restartUpdateCore': 'Riavvia / aggiorna runtime', 'bootCheck.unexpectedError': 'Errore inatteso del controllo di avvio', 'bootCheck.actionFailed': 'Qualcosa è andato storto. Riprova.', + 'bootCheck.portConflictTitle': "Impossibile avviare il motore dell'app", + 'bootCheck.portConflictBody': + 'Un altro processo sta usando la porta di rete necessaria a OpenHuman. Tenteremo di risolvere il problema automaticamente.', + 'bootCheck.portConflictFixButton': 'Correzione automatica', + 'bootCheck.portConflictFixing': 'Correzione in corso…', + 'bootCheck.portConflictFixFailed': + 'La correzione automatica non ha funzionato. Riavvia il computer e riprova.', 'notifications.justNow': 'adesso', 'notifications.minAgo': '{n}m fa', 'notifications.hrAgo': '{n}h fa', diff --git a/app/src/lib/i18n/chunks/ko-3.ts b/app/src/lib/i18n/chunks/ko-3.ts index 8c5fef0f7..e0439b070 100644 --- a/app/src/lib/i18n/chunks/ko-3.ts +++ b/app/src/lib/i18n/chunks/ko-3.ts @@ -188,6 +188,13 @@ const ko3: TranslationMap = { 'bootCheck.restartUpdateCore': '런타임 다시 시작 / 업데이트', 'bootCheck.unexpectedError': '예상치 못한 부트 체크 오류', 'bootCheck.actionFailed': '문제가 발생했습니다. 다시 시도해 주세요.', + 'bootCheck.portConflictTitle': '앱 엔진을 시작할 수 없습니다', + 'bootCheck.portConflictBody': + '다른 프로세스가 OpenHuman에 필요한 네트워크 포트를 사용 중입니다. 자동으로 문제를 해결해 드리겠습니다.', + 'bootCheck.portConflictFixButton': '자동 수정', + 'bootCheck.portConflictFixing': '수정 중…', + 'bootCheck.portConflictFixFailed': + '자동 수정에 실패했습니다. 컴퓨터를 재시작한 후 다시 시도해 주세요.', 'notifications.justNow': '방금 전', 'notifications.minAgo': '{n}분 전', 'notifications.hrAgo': '{n}시간 전', diff --git a/app/src/lib/i18n/chunks/pt-3.ts b/app/src/lib/i18n/chunks/pt-3.ts index f2437335e..0c4ec0baf 100644 --- a/app/src/lib/i18n/chunks/pt-3.ts +++ b/app/src/lib/i18n/chunks/pt-3.ts @@ -192,6 +192,13 @@ const pt3: TranslationMap = { 'bootCheck.restartUpdateCore': 'Reiniciar / Atualizar Runtime', 'bootCheck.unexpectedError': 'Erro Inesperado na Verificação de Inicialização', 'bootCheck.actionFailed': 'Algo deu errado. Por favor, tente novamente.', + 'bootCheck.portConflictTitle': 'Não foi possível iniciar o motor do aplicativo', + 'bootCheck.portConflictBody': + 'Outro processo está usando a porta de rede que o OpenHuman precisa. Tentaremos corrigir isso automaticamente.', + 'bootCheck.portConflictFixButton': 'Corrigir automaticamente', + 'bootCheck.portConflictFixing': 'Corrigindo…', + 'bootCheck.portConflictFixFailed': + 'A correção automática não funcionou. Reinicie o computador e tente novamente.', 'notifications.justNow': 'agora mesmo', 'notifications.minAgo': '{n}min atrás', 'notifications.hrAgo': '{n}h atrás', diff --git a/app/src/lib/i18n/chunks/ru-3.ts b/app/src/lib/i18n/chunks/ru-3.ts index e2da5b505..b17ca4c52 100644 --- a/app/src/lib/i18n/chunks/ru-3.ts +++ b/app/src/lib/i18n/chunks/ru-3.ts @@ -190,6 +190,13 @@ const ru3: TranslationMap = { 'bootCheck.restartUpdateCore': 'Перезапустить / обновить среду', 'bootCheck.unexpectedError': 'Неожиданная ошибка при загрузке', 'bootCheck.actionFailed': 'Что-то пошло не так. Попробуй ещё раз.', + 'bootCheck.portConflictTitle': 'Не удалось запустить движок приложения', + 'bootCheck.portConflictBody': + 'Другой процесс использует сетевой порт, необходимый OpenHuman. Попробуем устранить это автоматически.', + 'bootCheck.portConflictFixButton': 'Исправить автоматически', + 'bootCheck.portConflictFixing': 'Исправление…', + 'bootCheck.portConflictFixFailed': + 'Автоматическое исправление не сработало. Перезагрузите компьютер и попробуйте снова.', 'notifications.justNow': 'только что', 'notifications.minAgo': '{n} мин назад', 'notifications.hrAgo': '{n} ч назад', diff --git a/app/src/lib/i18n/chunks/zh-CN-3.ts b/app/src/lib/i18n/chunks/zh-CN-3.ts index eb8729a79..312fe048f 100644 --- a/app/src/lib/i18n/chunks/zh-CN-3.ts +++ b/app/src/lib/i18n/chunks/zh-CN-3.ts @@ -181,6 +181,12 @@ const zhCN3: TranslationMap = { 'bootCheck.restartUpdateCore': '重启 / 更新核心', 'bootCheck.unexpectedError': '意外的启动检查错误', 'bootCheck.actionFailed': '操作失败 — 请重试。', + 'bootCheck.portConflictTitle': '无法启动应用引擎', + 'bootCheck.portConflictBody': + '另一个进程正在占用 OpenHuman 所需的网络端口。我们将尝试自动修复此问题。', + 'bootCheck.portConflictFixButton': '自动修复', + 'bootCheck.portConflictFixing': '修复中…', + 'bootCheck.portConflictFixFailed': '自动修复未成功。请重启您的计算机后重试。', 'notifications.justNow': '刚刚', 'notifications.minAgo': '{n} 分钟前', 'notifications.hrAgo': '{n} 小时前', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index a8fed1723..967b710b5 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -1754,6 +1754,13 @@ const en: TranslationMap = { 'bootCheck.restartUpdateCore': 'Restart / Update Runtime', 'bootCheck.unexpectedError': 'Unexpected Boot-Check Error', 'bootCheck.actionFailed': 'Something went wrong. Please try again.', + 'bootCheck.portConflictTitle': "Couldn't Start the App Engine", + 'bootCheck.portConflictBody': + "Another process is using the network port OpenHuman needs. We'll try to fix this automatically.", + 'bootCheck.portConflictFixButton': 'Fix Automatically', + 'bootCheck.portConflictFixing': 'Fixing…', + 'bootCheck.portConflictFixFailed': + "Automatic fix didn't work. Please restart your computer and try again.", // Notifications: category labels & timestamps 'notifications.justNow': 'just now', diff --git a/app/src/services/bootCheckService.test.ts b/app/src/services/bootCheckService.test.ts index 7a617e98b..0f2cd2e33 100644 --- a/app/src/services/bootCheckService.test.ts +++ b/app/src/services/bootCheckService.test.ts @@ -36,3 +36,23 @@ describe('bootCheckTransport', () => { expect(invokeMock).toHaveBeenCalledWith('start_core_process', {}); }); }); + +describe('recoverPortConflict', () => { + it('calls the recover_port_conflict Tauri command and returns the result', async () => { + const fakeOutcome = { success: true, message: 'Core recovered on port 7789', new_port: 7789 }; + invokeMock.mockResolvedValueOnce(fakeOutcome); + + const { recoverPortConflict } = await import('./bootCheckService'); + const result = await recoverPortConflict(); + + expect(result).toEqual(fakeOutcome); + expect(invokeMock).toHaveBeenCalledWith('recover_port_conflict', undefined); + }); + + it('propagates errors from the Tauri command', async () => { + invokeMock.mockRejectedValueOnce(new Error('IPC failure')); + + const { recoverPortConflict } = await import('./bootCheckService'); + await expect(recoverPortConflict()).rejects.toThrow('IPC failure'); + }); +}); diff --git a/app/src/services/bootCheckService.ts b/app/src/services/bootCheckService.ts index d014eb2c5..9c267140d 100644 --- a/app/src/services/bootCheckService.ts +++ b/app/src/services/bootCheckService.ts @@ -20,4 +20,16 @@ async function invokeCmd(cmd: string, args?: Record): Promis return invoke(cmd, args); } -export const bootCheckTransport: BootCheckTransport = { callRpc, invokeCmd }; +/** + * Invoke the `recover_port_conflict` Tauri command to reap stale OpenHuman + * processes and restart the embedded core on any available port. + */ +export async function recoverPortConflict(): Promise<{ + success: boolean; + message: string; + new_port?: number; +}> { + return invokeCmd('recover_port_conflict'); +} + +export const bootCheckTransport: BootCheckTransport = { callRpc, invokeCmd, recoverPortConflict }; diff --git a/app/test/e2e/specs/mega-flow.spec.ts b/app/test/e2e/specs/mega-flow.spec.ts index 00f806e6f..1b9b1c455 100644 --- a/app/test/e2e/specs/mega-flow.spec.ts +++ b/app/test/e2e/specs/mega-flow.spec.ts @@ -237,6 +237,9 @@ describe('Mega flow — login + Gmail OAuth + Composio in one session', () => { // Re-login since reset wipes the session. await triggerDeepLink('openhuman://auth?token=mega-composio-token'); await waitForMockRequest('POST', '/telegram/login-tokens/', 15_000); + // Wait for the core to finish storing the session token before calling + // composio RPCs — without this the session may not be persisted yet. + await waitForMockRequest('GET', '/auth/me', 10_000); // Seed connections + available triggers; start with an empty active list. setMockBehaviors({ @@ -248,6 +251,7 @@ describe('Mega flow — login + Gmail OAuth + Composio in one session', () => { }); const before = await callOpenhumanRpc('openhuman.composio_list_triggers', {}); + if (!before.ok) console.log(`${LOG} composio_list_triggers FAILED:`, JSON.stringify(before)); expect(before.ok).toBe(true); // list_triggers always emits a log line → RpcOutcome wraps in {result, logs}. // JSON-RPC result shape: { result: { triggers: [...] }, logs: [...] } @@ -561,6 +565,8 @@ describe('Mega flow — login + Gmail OAuth + Composio in one session', () => { await triggerDeepLink('openhuman://auth?token=mega-composio-webhook-token'); await waitForMockRequest('POST', '/telegram/login-tokens/', 15_000); + // Wait for the core to finish storing the session token before proceeding. + await waitForMockRequest('GET', '/auth/me', 10_000); clearRequestLog(); // Seed composio state. @@ -577,6 +583,7 @@ describe('Mega flow — login + Gmail OAuth + Composio in one session', () => { connection_id: 'c2', slug: 'GITHUB_PULL_REQUEST_EVENT', }); + if (!enable.ok) console.log(`${LOG} composio_enable_trigger FAILED:`, JSON.stringify(enable)); expect(enable.ok).toBe(true); console.log(`${LOG} composio+webhook: trigger enabled`); diff --git a/tests/json_rpc_e2e.rs b/tests/json_rpc_e2e.rs index 7bc2fe783..bf09b471d 100644 --- a/tests/json_rpc_e2e.rs +++ b/tests/json_rpc_e2e.rs @@ -18,6 +18,7 @@ use tempfile::tempdir; use openhuman_core::core::auth::{init_rpc_token, CORE_TOKEN_ENV_VAR}; use openhuman_core::core::jsonrpc::build_core_http_router; +use openhuman_core::openhuman::connectivity::rpc::pick_listen_port; use openhuman_core::openhuman::memory_tree::all_memory_tree_registered_controllers; const TEST_RPC_TOKEN: &str = "json-rpc-e2e-local-token"; @@ -7705,3 +7706,102 @@ async fn json_rpc_config_autonomy_settings_roundtrip() { mock_join.abort(); rpc_join.abort(); } + +// --------------------------------------------------------------------------- +// Port-conflict recovery E2E +// --------------------------------------------------------------------------- +// +// Verifies that when the preferred core port (7788) is already occupied, the +// RPC stack starts successfully on a fallback port and remains fully +// reachable. A second pass confirms that once the blocker is dropped, port +// 7788 becomes available again — matching the "repro gone" acceptance +// criterion from issue #2617. + +#[tokio::test] +async fn port_conflict_recovery_core_starts_on_fallback_port_e2e() { + let _env_lock = json_rpc_e2e_env_lock(); + + // ── 1. occupy port 7788 with a dummy listener ───────────────────────── + // Use std::net so the binding is synchronous and stable before we call + // pick_listen_port. + let blocker = + std::net::TcpListener::bind("127.0.0.1:7788").expect("bind blocker on 7788 for e2e test"); + blocker + .set_nonblocking(true) + .expect("set blocker non-blocking"); + + // ── 2. pick_listen_port should fall back to 7789–7798 ──────────────── + let pick_result = pick_listen_port(7788) + .await + .expect("pick_listen_port must succeed when port is occupied by non-OpenHuman process"); + assert!( + pick_result.fallback_from.is_some(), + "expected fallback_from to be Some(7788) when preferred port is occupied, got None" + ); + assert_eq!( + pick_result.fallback_from, + Some(7788), + "fallback_from should record the originally preferred port" + ); + let fallback_port = pick_result.port; + assert!( + (7789..=7798).contains(&fallback_port), + "fallback port {fallback_port} should be in the 7789–7798 range" + ); + + // ── 3. serve the core router on the fallback listener ──────────────── + ensure_test_rpc_auth(); + let router = build_core_http_router(false); + let listener = + tokio::net::TcpListener::from_std(pick_result.listener.into_std().expect("into_std")) + .expect("from_std"); + let addr: SocketAddr = listener.local_addr().expect("local_addr"); + let rpc_join = tokio::spawn(async move { axum::serve(listener, router).await }); + let rpc_base = format!("http://{addr}"); + assert_eq!( + addr.port(), + fallback_port, + "server should be on the fallback port" + ); + + // ── 4. RPC health-check on the fallback port ───────────────────────── + let diag = post_json_rpc(&rpc_base, 26170, "openhuman.connectivity_diag", json!({})).await; + // JSON-RPC result envelope → inner cli-compatible wrapper → diag payload. + // post_json_rpc returns the raw JSON-RPC response; assert_no_jsonrpc_error + // unwraps the outer "result". The inner value is {"logs":[...],"result":{"diag":{...}}}, + // so we need one more "result" hop before accessing "diag". + let outer = assert_no_jsonrpc_error(&diag, "connectivity_diag on fallback port"); + let inner = outer.get("result").unwrap_or_else(|| { + panic!("connectivity_diag outer result missing 'result' key; got: {outer}") + }); + let diag_payload = inner.get("diag").unwrap_or_else(|| { + panic!("connectivity_diag inner result missing 'diag' key; got: {inner}") + }); + assert!( + diag_payload.get("sidecar_pid").is_some(), + "connectivity_diag should return sidecar_pid field; got: {diag_payload}" + ); + assert!( + diag_payload.get("listen_port").is_some(), + "connectivity_diag should return listen_port field; got: {diag_payload}" + ); + + rpc_join.abort(); + + // ── 5. drop blocker — verify 7788 is now free ──────────────────────── + drop(blocker); + + let after_drop = pick_listen_port(7788) + .await + .expect("pick_listen_port should succeed with 7788 free"); + assert_eq!( + after_drop.port, 7788, + "after releasing the blocker, pick_listen_port should bind directly on 7788" + ); + assert!( + after_drop.fallback_from.is_none(), + "fallback_from should be None when 7788 is free" + ); + // Release the listener so the port is not held across tests. + drop(after_drop.listener); +}