mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
fix(startup): recover from core port 7788 conflict automatically (#2626)
This commit is contained in:
@@ -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/<pid>/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.
|
||||
|
||||
@@ -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<u16>,
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<core_process::RecoveryOutcome, String> {
|
||||
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,
|
||||
|
||||
@@ -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/<pid>/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<u32> = 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<Vec<ProcessInfo>, 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/<pid>/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::<Vec<_>>();
|
||||
|
||||
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<u32> {
|
||||
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
|
||||
// /proc/<pid>/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<u32> = 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<Vec<ProcessInfo>, 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<ProcessInfo> {
|
||||
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<Vec<ProcessInfo>, String> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) use windows_imp::{enumerate_openhuman_processes, reap_stale_openhuman_processes};
|
||||
|
||||
@@ -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 (
|
||||
<Panel>
|
||||
<h2 className="text-xl font-semibold text-stone-900 dark:text-neutral-100">
|
||||
{t('bootCheck.cannotReach')}
|
||||
{isPortConflict ? t('bootCheck.portConflictTitle') : t('bootCheck.cannotReach')}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-stone-600 dark:text-neutral-300">
|
||||
{result.reason || t('bootCheck.cannotReachDesc')}
|
||||
{isPortConflict
|
||||
? t('bootCheck.portConflictBody')
|
||||
: result.reason || t('bootCheck.cannotReachDesc')}
|
||||
</p>
|
||||
{actionError && <p className="mt-3 text-xs text-red-600 font-medium">{actionError}</p>}
|
||||
<div className="mt-5 flex gap-3">
|
||||
<div className="mt-5 flex gap-3 flex-wrap">
|
||||
{isPortConflict && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAction}
|
||||
disabled={actionBusy}
|
||||
data-testid="fix-automatically-btn"
|
||||
className="rounded-lg bg-primary-500 px-4 py-2 text-sm font-medium text-white hover:bg-primary-600 disabled:opacity-60">
|
||||
{actionBusy
|
||||
? t('bootCheck.portConflictFixing')
|
||||
: t('bootCheck.portConflictFixButton')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
@@ -427,13 +442,15 @@ function ResultScreen({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSwitchMode}
|
||||
className="rounded-lg border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-4 py-2 text-sm text-stone-700 dark:text-neutral-200 hover:bg-stone-50 dark:hover:bg-neutral-800/60">
|
||||
disabled={actionBusy}
|
||||
className="rounded-lg border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-4 py-2 text-sm text-stone-700 dark:text-neutral-200 hover:bg-stone-50 dark:hover:bg-neutral-800/60 disabled:opacity-60">
|
||||
{t('bootCheck.switchMode')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onQuit}
|
||||
className="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700">
|
||||
disabled={actionBusy}
|
||||
className="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-60">
|
||||
{t('bootCheck.quit')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -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.
|
||||
|
||||
@@ -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<typeof import('../../../services/bootCheckService')>();
|
||||
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);
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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: <T>(method: string, params?: Record<string, unknown>) => Promise<T>;
|
||||
/** Invoke a Tauri command. */
|
||||
invokeCmd: <T>(cmd: string, args?: Record<string, unknown>) => Promise<T>;
|
||||
/** 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<void>('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.
|
||||
|
||||
@@ -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} س',
|
||||
|
||||
@@ -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} ঘণ্টা আগে',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}घं. पहले',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}시간 전',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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} ч назад',
|
||||
|
||||
@@ -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} 小时前',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,4 +20,16 @@ async function invokeCmd<T>(cmd: string, args?: Record<string, unknown>): Promis
|
||||
return invoke<T>(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 };
|
||||
|
||||
@@ -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`);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user