diff options
Diffstat (limited to 'src/server/autodetect.rs')
| -rw-r--r-- | src/server/autodetect.rs | 296 |
1 files changed, 296 insertions, 0 deletions
diff --git a/src/server/autodetect.rs b/src/server/autodetect.rs new file mode 100644 index 00000000..b0ca2a2e --- /dev/null +++ b/src/server/autodetect.rs @@ -0,0 +1,296 @@ +//! Auto-detect launch behavior for the `herdr` command. +//! +//! When the user runs `herdr` with no subcommand: +//! 1. Check if a server is already listening on the client socket +//! 2. If no server → spawn one as a background daemon → wait for socket readiness (up to 5s) +//! 3. Attach as a thin client to the server +//! +//! The `--no-session` flag bypasses server/client entirely and runs monolithically +//! (escape hatch for users who want the traditional single-process behavior). + +use std::io; +use std::os::unix::net::UnixStream; +use std::os::unix::process::CommandExt; +use std::path::Path; +use std::process::Command; +use std::time::Duration; + +use tracing::{info, warn}; + +use super::headless::client_socket_path; + +/// Maximum time to wait for the server's client socket to become ready +/// after spawning the server process. +const SERVER_READY_TIMEOUT: Duration = Duration::from_secs(5); + +/// Poll interval when waiting for the server socket to appear. +const SOCKET_POLL_INTERVAL: Duration = Duration::from_millis(50); + +// --------------------------------------------------------------------------- +// Server detection +// --------------------------------------------------------------------------- + +/// Checks whether a herdr server is currently listening on the client socket. +/// +/// This works by attempting to connect to the client socket. If the connection +/// succeeds, a server is running. If the socket file doesn't exist or the +/// connection is refused, no server is running. Stale sockets (from a crashed +/// server) are detected because `UnixStream::connect` returns `ConnectionRefused` +/// when nobody is listening. +#[allow(dead_code)] // Public API for external use and testing +pub fn is_server_listening() -> bool { + is_server_listening_at(&client_socket_path()) +} + +/// Checks whether a herdr server is listening at a specific socket path. +fn is_server_listening_at(socket_path: &Path) -> bool { + if !socket_path.exists() { + return false; + } + + match UnixStream::connect(socket_path) { + Ok(_) => { + // Server is listening. Close the test connection immediately. + // The server's handshake handler will time out on this connection + // since we don't send Hello, which is fine. + true + } + Err(err) + if matches!( + err.kind(), + io::ErrorKind::ConnectionRefused | io::ErrorKind::TimedOut + ) => + { + // Socket file exists but nobody is listening — stale socket. + false + } + Err(err) if err.kind() == io::ErrorKind::NotFound => { + // Socket file disappeared between exists() and connect(). + false + } + Err(err) => { + // Other errors (permission denied, etc.) — assume not listening. + warn!(err = %err, "unexpected error checking server socket"); + false + } + } +} + +// --------------------------------------------------------------------------- +// Server spawning +// --------------------------------------------------------------------------- + +/// Spawns the herdr server as a background daemon process. +/// +/// The server process is fully detached: +/// - Runs in its own session (setsid) so it survives the client exiting +/// - Stdin/stdout/stderr are redirected to /dev/null +/// - Inherits all relevant environment variables +/// (`HERDR_SOCKET_PATH`, `HERDR_CLIENT_SOCKET_PATH`, `XDG_CONFIG_HOME`, etc.) +/// +/// Returns the PID of the spawned server process. +pub fn spawn_server_daemon() -> io::Result<u32> { + let exe = std::env::current_exe().map_err(|err| { + io::Error::new( + err.kind(), + format!("failed to determine herdr executable path: {err}"), + ) + })?; + + info!(exe = %exe.display(), "spawning server daemon"); + + let child = Command::new(&exe) + .arg("server") + // Create a new process group so the server survives the parent's exit + // and doesn't receive SIGHUP when the client's terminal closes. + .process_group(0) + // Redirect stdio to /dev/null + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .map_err(|err: io::Error| { + io::Error::new(err.kind(), format!("failed to spawn herdr server: {err}")) + })?; + + let pid = child.id(); + info!(pid, "server daemon spawned"); + + Ok(pid) +} + +// --------------------------------------------------------------------------- +// Socket readiness +// --------------------------------------------------------------------------- + +/// Waits for the server's client socket to become ready for connections. +/// +/// Polls the socket path at regular intervals until a connection succeeds +/// or the timeout elapses. Returns an error if the server doesn't become +/// ready within the timeout. +pub fn wait_for_server_socket(socket_path: &Path, timeout: Duration) -> io::Result<()> { + let deadline = std::time::Instant::now() + timeout; + + while std::time::Instant::now() < deadline { + if is_server_listening_at(socket_path) { + info!(path = %socket_path.display(), "server socket ready"); + return Ok(()); + } + std::thread::sleep(SOCKET_POLL_INTERVAL); + } + + Err(io::Error::new( + io::ErrorKind::TimedOut, + format!( + "server did not become ready within {}s (socket: {})", + timeout.as_secs(), + socket_path.display() + ), + )) +} + +// --------------------------------------------------------------------------- +// Auto-detect launch +// --------------------------------------------------------------------------- + +/// Performs auto-detect launch: check for server, spawn if needed, then +/// attach as a thin client. +/// +/// This is the entry point called from `main.rs` when the user runs `herdr` +/// without `--no-session` and without a subcommand. +/// +/// Flow: +/// 1. Check if a server is listening on the client socket +/// 2. If no server → spawn server daemon → wait for socket readiness +/// 3. Run the thin client (which connects to the server) +pub fn auto_detect_launch() -> io::Result<()> { + let socket_path = client_socket_path(); + info!(path = %socket_path.display(), "auto-detect launch starting"); + + if is_server_listening_at(&socket_path) { + info!("server already running, attaching as client"); + } else { + info!("no server running, spawning server daemon"); + spawn_server_daemon()?; + wait_for_server_socket(&socket_path, SERVER_READY_TIMEOUT)?; + info!("server ready, attaching as client"); + } + + // Now attach as a thin client. + crate::client::run_client() +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use std::os::unix::net::UnixListener; + + fn temp_socket_path(name: &str) -> std::path::PathBuf { + let unique = format!( + "herdr-autodetect-test-{}-{}-{}", + name, + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + ); + std::env::temp_dir().join(unique).join("test.sock") + } + + #[test] + fn is_server_listening_returns_false_for_nonexistent_path() { + let path = temp_socket_path("nonexistent"); + assert!(!is_server_listening_at(&path)); + } + + #[test] + fn is_server_listening_returns_true_for_live_socket() { + let dir = temp_socket_path("live"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("test.sock"); + + let _listener = UnixListener::bind(&path).unwrap(); + assert!(is_server_listening_at(&path)); + } + + #[test] + fn is_server_listening_returns_false_for_stale_socket() { + let dir = temp_socket_path("stale"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("test.sock"); + + // Create a socket and immediately drop the listener. + // This leaves a stale socket file with nobody listening. + { + let _listener = UnixListener::bind(&path).unwrap(); + } + + // The socket file exists but nobody is listening. + assert!(!is_server_listening_at(&path)); + } + + #[test] + fn is_server_listening_returns_false_when_listener_dropped() { + let dir = temp_socket_path("dropped"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("test.sock"); + + // Bind and immediately drop the listener. + drop(UnixListener::bind(&path).unwrap()); + + // Socket is stale — should return false. + assert!(!is_server_listening_at(&path)); + + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn wait_for_server_socket_succeeds_immediately() { + let dir = temp_socket_path("wait-ok"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("test.sock"); + + let _listener = UnixListener::bind(&path).unwrap(); + + // Should succeed immediately (socket is already ready). + let result = wait_for_server_socket(&path, Duration::from_millis(100)); + assert!(result.is_ok()); + } + + #[test] + fn wait_for_server_socket_times_out() { + let dir = temp_socket_path("wait-timeout"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("test.sock"); + + // No listener — should time out. + let result = wait_for_server_socket(&path, Duration::from_millis(50)); + assert!(result.is_err()); + assert_eq!(result.unwrap_err().kind(), io::ErrorKind::TimedOut); + } + + #[test] + fn wait_for_server_socket_succeeds_after_delay() { + let dir = temp_socket_path("wait-delay"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("test.sock"); + + // Spawn a thread that will create the listener after a short delay. + let path_clone = path.clone(); + std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(50)); + let _listener = UnixListener::bind(&path_clone).unwrap(); + // Keep the listener alive for a bit. + std::thread::sleep(Duration::from_secs(1)); + }); + + // Wait with a generous timeout — should succeed. + let result = wait_for_server_socket(&path, Duration::from_secs(2)); + assert!(result.is_ok()); + } +} |
