aboutsummaryrefslogtreecommitdiff
path: root/src/server/autodetect.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/server/autodetect.rs')
-rw-r--r--src/server/autodetect.rs296
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());
+ }
+}