mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-29 14:02:19 +00:00
827 lines
29 KiB
Rust
827 lines
29 KiB
Rust
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, parse_ps_comm, parse_tasklist_name,
|
||
validate_kill_target, CoreProcessHandle, PortOwner, 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"
|
||
);
|
||
}
|
||
|
||
/// Regression: `ensure_running` must NOT publish the per-launch RPC bearer
|
||
/// to the `OPENHUMAN_CORE_TOKEN` environment variable.
|
||
///
|
||
/// The bearer is now handed to the in-process core in-memory via the
|
||
/// `rpc_token` argument of `run_server_embedded_with_ready`; setting it on
|
||
/// the process env would put it within reach of any same-UID process
|
||
/// reading `/proc/<pid>/environ` (Linux) or `sysctl KERN_PROCARGS2` /
|
||
/// `ps eww -p <pid>` (macOS).
|
||
#[test]
|
||
fn ensure_running_does_not_publish_token_to_env() {
|
||
let _env_lock = env_lock();
|
||
let _unset = EnvGuard::unset("OPENHUMAN_CORE_REUSE_EXISTING");
|
||
// Force a clean slate so we can assert on the post-spawn value.
|
||
let _wipe = EnvGuard::unset("OPENHUMAN_CORE_TOKEN");
|
||
let rt = tokio::runtime::Runtime::new().expect("runtime");
|
||
let (result, env_after, expected_token, env_during_spawn) = 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();
|
||
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 expected_token = handle.rpc_token().to_string();
|
||
let result = handle.ensure_running().await;
|
||
// Capture env immediately after spawn returns Ok — before any
|
||
// tokio task could plausibly have set the var.
|
||
let env_after = std::env::var("OPENHUMAN_CORE_TOKEN").ok();
|
||
// Also peek midway via spawning a tiny check task in the same
|
||
// runtime — guards against the codepath setting+removing the var
|
||
// within the spawn window.
|
||
let env_during_spawn = std::env::var("OPENHUMAN_CORE_TOKEN").ok();
|
||
handle.shutdown().await;
|
||
(result, env_after, expected_token, env_during_spawn)
|
||
});
|
||
|
||
assert!(
|
||
result.is_ok(),
|
||
"ensure_running should succeed against a freed port: {result:?}"
|
||
);
|
||
assert!(
|
||
env_after.is_none(),
|
||
"ensure_running must NOT publish OPENHUMAN_CORE_TOKEN to the process env \
|
||
(sidecar-era leak channel removed). Found: {env_after:?} (handle token was {expected_token:?})"
|
||
);
|
||
assert!(
|
||
env_during_spawn.is_none(),
|
||
"OPENHUMAN_CORE_TOKEN must remain unset even momentarily during spawn. \
|
||
Found: {env_during_spawn:?}"
|
||
);
|
||
}
|
||
|
||
/// 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(¬ice.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, 2).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=19006"),
|
||
"timeout message should include RPC port: {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!(
|
||
message.contains("attempt=2"),
|
||
"timeout message should include startup attempt: {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),
|
||
foreign_owner: None,
|
||
};
|
||
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,
|
||
foreign_owner: 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"
|
||
);
|
||
assert!(
|
||
json["foreign_owner"].is_null(),
|
||
"foreign_owner should be null when None"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn recovery_outcome_serializes_foreign_owner() {
|
||
let outcome = RecoveryOutcome {
|
||
success: false,
|
||
message: "Recovery failed: port still busy".to_string(),
|
||
new_port: None,
|
||
foreign_owner: Some(PortOwner {
|
||
pid: 4242,
|
||
name: "Skype.exe".to_string(),
|
||
}),
|
||
};
|
||
let json = serde_json::to_value(&outcome).expect("serialize");
|
||
assert_eq!(json["foreign_owner"]["pid"], serde_json::json!(4242));
|
||
assert_eq!(
|
||
json["foreign_owner"]["name"],
|
||
serde_json::json!("Skype.exe")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn parse_tasklist_name_extracts_image_name() {
|
||
assert_eq!(
|
||
parse_tasklist_name("\"chrome.exe\",\"1234\",\"Console\",\"1\",\"123,456 K\"\r\n"),
|
||
Some("chrome.exe".to_string())
|
||
);
|
||
// Leading blank lines are skipped.
|
||
assert_eq!(
|
||
parse_tasklist_name("\n\"node.exe\",\"42\",\"Console\",\"1\",\"9,000 K\""),
|
||
Some("node.exe".to_string())
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn parse_tasklist_name_rejects_no_tasks_sentinel_and_blanks() {
|
||
assert_eq!(
|
||
parse_tasklist_name("INFO: No tasks are running which match the specified criteria.\r\n"),
|
||
None
|
||
);
|
||
assert_eq!(parse_tasklist_name(""), None);
|
||
assert_eq!(parse_tasklist_name("\"\",\"1\""), None);
|
||
}
|
||
|
||
#[test]
|
||
fn parse_ps_comm_takes_basename() {
|
||
assert_eq!(parse_ps_comm("nginx\n"), Some("nginx".to_string()));
|
||
assert_eq!(
|
||
parse_ps_comm("/usr/lib/postgresql/16/bin/postgres\n"),
|
||
Some("postgres".to_string())
|
||
);
|
||
assert_eq!(parse_ps_comm(" \n"), None);
|
||
assert_eq!(parse_ps_comm(""), None);
|
||
}
|
||
|
||
#[test]
|
||
fn validate_kill_target_accepts_unchanged_owner() {
|
||
assert_eq!(validate_kill_target(Some(1234), 1234, 999), Ok(1234));
|
||
}
|
||
|
||
#[test]
|
||
fn validate_kill_target_refuses_when_owner_gone_or_changed() {
|
||
// Port freed between surfacing the owner and confirming.
|
||
assert!(validate_kill_target(None, 1234, 999).is_err());
|
||
// A different process now holds the port — never kill what the user did
|
||
// not consent to (PID-reuse / race guard).
|
||
assert!(validate_kill_target(Some(5678), 1234, 999).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn validate_kill_target_refuses_self_pid() {
|
||
let err = validate_kill_target(Some(999), 999, 999).unwrap_err();
|
||
assert!(err.contains("own process"), "got: {err}");
|
||
}
|
||
|
||
#[test]
|
||
fn validate_kill_target_refuses_protected_pids() {
|
||
// Low/kernel pids must never be signalled even if the user "consented":
|
||
// 0 (kill(0) hits the process group on Unix), 1 (init/launchd), 4 (NT kernel).
|
||
assert!(validate_kill_target(Some(0), 0, 999).is_err());
|
||
assert!(validate_kill_target(Some(1), 1, 999).is_err());
|
||
assert!(validate_kill_target(Some(4), 4, 999).is_err());
|
||
}
|
||
|
||
#[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());
|
||
}
|
||
}
|