Files
openhuman/app/src-tauri/src/core_process_tests.rs
T

676 lines
24 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, RecoveryOutcome,
};
use std::sync::{Mutex, MutexGuard, OnceLock};
fn env_lock() -> MutexGuard<'static, ()> {
static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
// Recover from poison: when one test panics while holding this lock
// (e.g. an embedded-core readiness timeout under CI load), every
// subsequent test in the suite would otherwise cascade-fail with
// "env lock poisoned" — turning one real flake into three. The lock
// only serializes process-wide env-var mutation; the inner `()`
// carries no state that poisoning could corrupt.
ENV_LOCK
.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
struct EnvGuard {
key: &'static str,
old: Option<String>,
}
impl EnvGuard {
fn set(key: &'static str, value: &str) -> Self {
let old = std::env::var(key).ok();
std::env::set_var(key, value);
Self { key, old }
}
fn unset(key: &'static str) -> Self {
let old = std::env::var(key).ok();
std::env::remove_var(key);
Self { key, old }
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
match &self.old {
Some(v) => std::env::set_var(self.key, v),
None => std::env::remove_var(self.key),
}
}
}
#[test]
fn default_core_port_env_and_fallback() {
let _env_lock = env_lock();
let _unset = EnvGuard::unset("OPENHUMAN_CORE_PORT");
assert_eq!(default_core_port(), 7788);
let _set = EnvGuard::set("OPENHUMAN_CORE_PORT", "8899");
assert_eq!(default_core_port(), 8899);
}
#[test]
fn core_process_handle_new_creates_instance() {
let handle = CoreProcessHandle::new(9999);
assert_eq!(handle.port(), 9999);
assert_eq!(handle.rpc_url(), "http://127.0.0.1:9999/rpc");
}
#[test]
fn ready_signal_updates_runtime_port_and_fallback_notice() {
let handle = CoreProcessHandle::new(7788);
handle.apply_embedded_ready_signal(openhuman_core::core::jsonrpc::EmbeddedReadySignal {
port: 7789,
fallback_from: Some(7788),
});
assert_eq!(handle.port(), 7789);
assert_eq!(handle.rpc_url(), "http://127.0.0.1:7789/rpc");
let notice = handle
.take_last_port_fallback_notice()
.expect("fallback notice should be present");
assert_eq!(notice.preferred_port, 7788);
assert_eq!(notice.chosen_port, 7789);
assert!(
handle.take_last_port_fallback_notice().is_none(),
"fallback notice should be consumed once"
);
}
/// Issue #1613: when the preferred port is occupied by a non-OpenHuman
/// listener, startup should fall back to a nearby port instead of failing.
#[test]
fn ensure_running_falls_back_for_unknown_listener_on_port() {
let _env_lock = env_lock();
let _unset = EnvGuard::unset("OPENHUMAN_CORE_REUSE_EXISTING");
let rt = tokio::runtime::Runtime::new().expect("runtime");
let (result, chosen_port, notice) = rt.block_on(async {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind test listener");
let port = listener.local_addr().expect("local addr").port();
let handle = CoreProcessHandle::new(port);
let result = handle.ensure_running().await;
let chosen_port = handle.port();
let notice = handle.take_last_port_fallback_notice();
handle.shutdown().await;
(result, chosen_port, notice)
});
assert!(
result.is_ok(),
"ensure_running should recover via fallback when preferred port is occupied: {result:?}"
);
assert!(
notice.is_some(),
"fallback notice should be set when preferred port is occupied"
);
let notice = notice.expect("notice set");
assert_ne!(
chosen_port, notice.preferred_port,
"fallback must choose a different port"
);
assert_eq!(
chosen_port, notice.chosen_port,
"chosen port should match fallback notice payload"
);
}
#[test]
fn ensure_running_falls_back_to_7789_when_7788_is_busy() {
let _env_lock = env_lock();
let _unset = EnvGuard::unset("OPENHUMAN_CORE_REUSE_EXISTING");
let rt = tokio::runtime::Runtime::new().expect("runtime");
rt.block_on(async {
let listener = match tokio::net::TcpListener::bind("127.0.0.1:7788").await {
Ok(listener) => listener,
Err(err) => {
eprintln!(
"[core_process tests] skipping fixed-port fallback test; 7788 unavailable: {err}"
);
return;
}
};
let handle = CoreProcessHandle::new(7788);
let result = handle.ensure_running().await;
assert!(
result.is_ok(),
"ensure_running should recover by binding a fallback port: {result:?}"
);
// Accept any port in the configured fallback range 7789..=7798 — a
// parallel test or environmental squatter on a single fallback port
// shouldn't fail the broader contract that fallback recovery works.
let chosen = handle.port();
assert!(
(7789..=7798).contains(&chosen),
"with 7788 occupied, core should bind to a fallback in 7789..=7798, got {chosen}"
);
let notice = handle
.take_last_port_fallback_notice()
.expect("fallback notice should be present");
assert_eq!(notice.preferred_port, 7788);
assert_eq!(
notice.chosen_port, chosen,
"fallback notice payload should match the bound port"
);
assert!(
(7789..=7798).contains(&notice.chosen_port),
"fallback notice chosen_port should be in 7789..=7798, got {}",
notice.chosen_port
);
handle.shutdown().await;
drop(listener);
});
}
/// Escape hatch: setting `OPENHUMAN_CORE_REUSE_EXISTING=1` opts back into
/// the legacy attach-to-anything behavior for manual harnesses.
#[test]
fn ensure_running_reuses_unknown_listener_when_override_set() {
let _env_lock = env_lock();
let _override = EnvGuard::set("OPENHUMAN_CORE_REUSE_EXISTING", "1");
let rt = tokio::runtime::Runtime::new().expect("runtime");
let result = rt.block_on(async {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind test listener");
let port = listener.local_addr().expect("local addr").port();
let handle = CoreProcessHandle::new(port);
handle.ensure_running().await
});
assert!(
result.is_ok(),
"override should restore legacy fast-path: {result:?}"
);
}
// ---------------------------------------------------------------------------
// Listener fingerprinting (issue #1130)
// ---------------------------------------------------------------------------
#[test]
fn is_openhuman_root_body_matches_canonical_root_response() {
// Mirrors the JSON shape produced by `core/jsonrpc.rs::root_handler`.
let body = r#"{
"name": "openhuman",
"ok": true,
"endpoints": {"health": "/health", "rpc": "/rpc"}
}"#;
assert!(is_openhuman_root_body(body));
}
#[test]
fn is_openhuman_root_body_rejects_other_services() {
assert!(!is_openhuman_root_body(r#"{"name": "something-else"}"#));
assert!(!is_openhuman_root_body(r#"{"ok": true}"#));
assert!(!is_openhuman_root_body("not json at all"));
assert!(!is_openhuman_root_body(""));
// Wrong type for `name`.
assert!(!is_openhuman_root_body(r#"{"name": 42}"#));
}
#[test]
fn expected_port_clash_classifier_matches_benign_probe_shapes() {
assert!(is_expected_port_clash(
"probe GET / failed: error sending request for url (http://127.0.0.1:7788/)"
));
assert!(is_expected_port_clash(
"probe GET / failed: connection refused"
));
assert!(is_expected_port_clash(
"probe GET / returned status 404 Not Found"
));
assert!(is_expected_port_clash("probe GET / returned status 200 OK"));
assert!(is_expected_port_clash(
"probe GET / body did not identify as openhuman (\"hello\")"
));
}
#[test]
fn expected_port_clash_classifier_matches_windows_acl_bind_shapes() {
assert!(is_expected_port_clash(
"Failed to bind to 127.0.0.1:7788: access denied (os error 10013)"
));
assert!(is_expected_port_clash(
"Failed to bind to 127.0.0.1:7788: WSAEACCES"
));
}
#[test]
fn expected_port_clash_classifier_rejects_unknown_probe_shapes() {
assert!(!is_expected_port_clash(
"probe GET / failed: TLS handshake failed: protocol error"
));
assert!(!is_expected_port_clash(
"probe GET / body read failed: unexpected eof"
));
}
#[test]
fn parse_lsof_pid_picks_first_pid() {
assert_eq!(parse_lsof_pid("12345\n"), Some(12345));
// Multiple pids — pick the first non-empty line. lsof can emit several
// when multiple sockets share the port (IPv4/IPv6).
assert_eq!(parse_lsof_pid("\n 9876 \n12345\n"), Some(9876));
assert_eq!(parse_lsof_pid(""), None);
assert_eq!(parse_lsof_pid("not-a-pid\n"), None);
}
#[test]
fn parse_netstat_pid_finds_listening_entry() {
// Sample shape from `netstat -ano -p TCP` on Windows.
let stdout = "\
Active Connections
Proto Local Address Foreign Address State PID
TCP 0.0.0.0:135 0.0.0.0:0 LISTENING 1024
TCP 127.0.0.1:7788 0.0.0.0:0 LISTENING 4242
TCP 127.0.0.1:50000 127.0.0.1:7788 ESTABLISHED 5555
";
assert_eq!(parse_netstat_pid(stdout, 7788), Some(4242));
assert_eq!(parse_netstat_pid(stdout, 9999), None);
}
#[test]
fn parse_netstat_pid_skips_protected_kernel_pids() {
// HTTP.sys / driver-level reservations occasionally show as LISTENING
// under PID 4 (NT Kernel) or PID 0 (System Idle). Returning those pids
// would lead startup recovery to call taskkill on a process that cannot
// be signalled from user mode — aborting the entire takeover flow.
// The parser must treat these entries as "no owner" so callers fall
// back to the port-reroute path instead of trying to kill the kernel.
let stdout = "\
Active Connections
Proto Local Address Foreign Address State PID
TCP 127.0.0.1:7788 0.0.0.0:0 LISTENING 4
TCP 127.0.0.1:7789 0.0.0.0:0 LISTENING 0
TCP 127.0.0.1:7790 0.0.0.0:0 LISTENING 1234
";
assert_eq!(parse_netstat_pid(stdout, 7788), None);
assert_eq!(parse_netstat_pid(stdout, 7789), None);
assert_eq!(parse_netstat_pid(stdout, 7790), Some(1234));
}
#[test]
fn parse_netstat_pid_falls_through_protected_to_real_owner_on_dual_stack() {
// Real-world dual-stack listener: kernel-reserved entry sits ahead of
// the actual user-mode owner on the same port. The parser must keep
// scanning past the protected pid and return the genuine owner.
let stdout = "\
Proto Local Address Foreign Address State PID
TCP [::]:7788 [::]:0 LISTENING 4
TCP 127.0.0.1:7788 0.0.0.0:0 LISTENING 9999
";
assert_eq!(parse_netstat_pid(stdout, 7788), Some(9999));
}
// ---------------------------------------------------------------------------
// Windows end-to-end port-takeover test
//
// Spawns a real child process that occupies a TCP port, then walks the same
// path the Tauri host walks at startup (find_pid_on_port → kill_pid_force →
// is_port_open) and asserts the port is actually freed. This is the
// behavior the user reported broken — a unit-only parser test is not enough
// to catch netstat/taskkill drift on real Windows machines.
// ---------------------------------------------------------------------------
#[cfg(windows)]
#[test]
fn windows_port_takeover_finds_and_kills_listener() {
use crate::process_kill::kill_pid_force;
use std::net::TcpListener;
use std::os::windows::process::CommandExt;
use std::time::{Duration, Instant};
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
// Bind in this process first to claim an ephemeral free port the OS
// picks for us, capture the port, then drop the listener so the child
// can bind to the same port. There is a tiny TOCTOU window here but
// ephemeral ports on Windows are not aggressively recycled so it is
// robust enough for a single-shot test.
let probe = TcpListener::bind("127.0.0.1:0").expect("bind probe");
let port = probe.local_addr().expect("probe addr").port();
drop(probe);
// Use PowerShell to spawn a listener that holds the port open for 60s.
// PowerShell ships with every supported Windows version.
let script = format!(
"$l = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, {port}); \
$l.Start(); Start-Sleep -Seconds 60; $l.Stop()"
);
let mut child = std::process::Command::new("powershell")
.args(["-NoProfile", "-NonInteractive", "-Command", &script])
.creation_flags(CREATE_NO_WINDOW)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.stdin(std::process::Stdio::null())
.spawn()
.expect("spawn powershell listener");
// Wait until the listener is actually bound (PowerShell startup is slow).
let deadline = Instant::now() + Duration::from_secs(15);
let mut bound = false;
while Instant::now() < deadline {
if std::net::TcpStream::connect_timeout(
&format!("127.0.0.1:{port}").parse().unwrap(),
Duration::from_millis(100),
)
.is_ok()
{
bound = true;
break;
}
std::thread::sleep(Duration::from_millis(100));
}
if !bound {
let _ = child.kill();
let _ = child.wait();
panic!("child listener never bound to 127.0.0.1:{port}");
}
// Walk the production path: pid lookup via netstat, then force-kill.
let pid = match super::find_pid_on_port(port) {
Some(pid) => pid,
None => {
let _ = child.kill();
let _ = child.wait();
panic!("find_pid_on_port returned None for port {port}");
}
};
// The pid we discovered won't be `child.id()` directly — the powershell
// process is the listener, and on Windows `child.id()` IS that pid.
// Sanity-check they match so a future netstat parser regression is loud.
// Tear down the child *before* panicking so a 60s listener doesn't leak
// into the rest of the test suite.
if pid != child.id() {
let expected = child.id();
let _ = child.kill();
let _ = child.wait();
panic!("find_pid_on_port returned pid {pid}, expected child pid {expected}");
}
kill_pid_force(pid).expect("force-kill listener");
// Verify the port is actually free within a reasonable window — this is
// the assertion that fails when taskkill mis-reports success or when
// /T fails to take down the powershell subtree.
let deadline = Instant::now() + Duration::from_secs(5);
let mut freed = false;
while Instant::now() < deadline {
if std::net::TcpStream::connect_timeout(
&format!("127.0.0.1:{port}").parse().unwrap(),
Duration::from_millis(100),
)
.is_err()
{
freed = true;
break;
}
std::thread::sleep(Duration::from_millis(100));
}
let _ = child.wait();
assert!(
freed,
"port {port} still bound after kill_pid_force(pid={pid})"
);
// Idempotency: kill the same pid again — must be Ok, not Err, because
// the process is already gone and recovery code calls force-kill after
// a re-validation that may race.
kill_pid_force(pid).expect("kill_pid_force on dead pid must be idempotent");
}
// ---------------------------------------------------------------------------
// Token generation tests
// ---------------------------------------------------------------------------
/// `generate_rpc_token` must produce a 64-character lowercase hex string
/// (32 bytes × 2 hex digits = 64 chars), matching the format expected by the
/// core's auth middleware.
#[test]
fn generate_rpc_token_produces_64_hex_chars() {
let token = generate_rpc_token();
assert_eq!(
token.len(),
64,
"256-bit token → 64 hex chars, got {token:?}"
);
assert!(
token.chars().all(|c| c.is_ascii_hexdigit()),
"token must be hex, got {token:?}"
);
assert!(
token.chars().all(|c| !c.is_uppercase()),
"token must be lowercase hex, got {token:?}"
);
}
/// Each call generates a different token (CSPRNG — not a constant).
#[test]
fn generate_rpc_token_is_not_constant() {
assert_ne!(
generate_rpc_token(),
generate_rpc_token(),
"two consecutive tokens must differ"
);
}
/// `CoreProcessHandle::new` must produce a non-empty, correctly-formatted
/// bearer token immediately — no file I/O or timing dependency.
#[test]
fn core_process_handle_new_token_is_valid() {
let handle = CoreProcessHandle::new(19001);
let token = handle.rpc_token();
assert_eq!(token.len(), 64, "handle token must be 64 hex chars");
assert!(
token.chars().all(|c| c.is_ascii_hexdigit()),
"handle token must be hex"
);
}
/// `CoreProcessHandle::new()` must NOT publish the token to the global
/// `CURRENT_RPC_TOKEN`. The global is set only after `ensure_running()`
/// successfully spawns the embedded server with `OPENHUMAN_CORE_TOKEN` in
/// scope. Advertising the token before spawn would 401 against any process
/// already listening on the port that never received this token.
#[test]
fn new_does_not_publish_global_token() {
let before = current_rpc_token();
let handle = CoreProcessHandle::new(19002);
let after = current_rpc_token();
assert_ne!(
after.as_deref(),
Some(handle.rpc_token()),
"new() must not publish its token to CURRENT_RPC_TOKEN before ensure_running() spawns"
);
assert_eq!(
before, after,
"new() must leave CURRENT_RPC_TOKEN unchanged"
);
}
/// Two handles constructed sequentially must each have a unique token.
#[test]
fn each_handle_has_unique_token() {
let h1 = CoreProcessHandle::new(19003);
let h2 = CoreProcessHandle::new(19004);
assert_ne!(
h1.rpc_token(),
h2.rpc_token(),
"each handle must have a unique token"
);
}
#[test]
fn send_terminate_signal_cancels_shutdown_token() {
let rt = tokio::runtime::Runtime::new().expect("runtime");
rt.block_on(async {
let handle = CoreProcessHandle::new(19005);
assert!(!handle.shutdown_token_is_cancelled().await);
handle.send_terminate_signal().await;
assert!(
handle.shutdown_token_is_cancelled().await,
"send_terminate_signal must cancel graceful Axum shutdown before aborting the task"
);
});
}
#[test]
fn startup_timeout_cleanup_aborts_task_and_clears_slot() {
let rt = tokio::runtime::Runtime::new().expect("runtime");
rt.block_on(async {
let handle = CoreProcessHandle::new(19006);
let task = tokio::spawn(async {
tokio::time::sleep(tokio::time::Duration::from_secs(60)).await;
Ok::<(), anyhow::Error>(())
});
{
let mut guard = handle.task.lock().await;
*guard = Some(task);
}
let message = handle.cleanup_startup_timeout(false, false).await;
assert!(
message.contains("core process did not become ready within"),
"timeout message should include the readiness budget: {message}"
);
assert!(
message.contains("ready_signal=false"),
"timeout message should include ready signal state: {message}"
);
assert!(
message.contains("port_open=false"),
"timeout message should include final port state: {message}"
);
assert!(
message.contains("task_state=running"),
"timeout message should include task state: {message}"
);
assert!(
handle.task.lock().await.is_none(),
"cleanup must clear the managed task slot so retry can spawn fresh"
);
assert!(
handle.shutdown_token_is_cancelled().await,
"cleanup must cancel the startup token before aborting"
);
});
}
// ---------------------------------------------------------------------------
// 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());
}
}