fix(startup): recover from core port 7788 conflict automatically (#2626)

This commit is contained in:
Mega Mind
2026-05-25 20:09:08 +05:30
committed by GitHub
parent d997394116
commit cdee8f73fb
27 changed files with 1209 additions and 45 deletions
+9
View File
@@ -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 77897798 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 (0005) defining the full PR workflow: pick issue → architectobot plan → user approval → codecrusher → architectobot verify → checks → memory-keeper → commit → push/PR.
+56 -1
View File
@@ -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()
+103 -1
View File
@@ -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());
}
}
+22
View File
@@ -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,
+483 -28
View File
@@ -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);
+86
View File
@@ -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
// ---------------------------------------------------------------------------
+50 -8
View File
@@ -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.
+7
View File
@@ -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} س',
+7
View File
@@ -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} ঘণ্টা আগে',
+7
View File
@@ -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',
+7
View File
@@ -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',
+7
View File
@@ -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',
+7
View File
@@ -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',
+7
View File
@@ -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}घं. पहले',
+7
View File
@@ -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',
+7
View File
@@ -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',
+7
View File
@@ -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}시간 전',
+7
View File
@@ -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',
+7
View File
@@ -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} ч назад',
+6
View File
@@ -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} 小时前',
+7
View File
@@ -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',
+20
View File
@@ -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');
});
});
+13 -1
View File
@@ -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 };
+7
View File
@@ -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`);
+100
View File
@@ -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 77897798 ────────────────
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 77897798 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);
}