diff options
Diffstat (limited to 'src/server')
| -rw-r--r-- | src/server/autodetect.rs | 153 |
1 files changed, 152 insertions, 1 deletions
diff --git a/src/server/autodetect.rs b/src/server/autodetect.rs index 8f695467..7cd505fb 100644 --- a/src/server/autodetect.rs +++ b/src/server/autodetect.rs @@ -8,7 +8,7 @@ //! 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::io::{self, BufRead, BufReader, Write}; use std::os::unix::net::UnixStream; use std::os::unix::process::CommandExt; use std::path::Path; @@ -27,6 +27,9 @@ 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); +/// Timeout for checking the stable JSON API before attaching to the binary protocol socket. +const STATUS_REQUEST_TIMEOUT: Duration = Duration::from_secs(2); + // --------------------------------------------------------------------------- // Server detection // --------------------------------------------------------------------------- @@ -43,6 +46,12 @@ pub fn is_server_listening() -> bool { is_server_listening_at(&client_socket_path()) } +#[derive(Debug, Clone, PartialEq, Eq)] +struct ServerStatus { + version: Option<String>, + protocol: Option<u32>, +} + /// 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() { @@ -77,6 +86,99 @@ fn is_server_listening_at(socket_path: &Path) -> bool { } } +fn read_server_status_at( + socket_path: &Path, + timeout: Duration, +) -> io::Result<Option<ServerStatus>> { + use crate::api::schema::{Method, PingParams, Request}; + + if !socket_path.exists() { + return Ok(None); + } + + let mut stream = match UnixStream::connect(socket_path) { + Ok(stream) => stream, + Err(err) + if matches!( + err.kind(), + io::ErrorKind::ConnectionRefused + | io::ErrorKind::NotFound + | io::ErrorKind::TimedOut + ) => + { + return Ok(None); + } + Err(err) => return Err(err), + }; + + stream.set_write_timeout(Some(timeout))?; + stream.set_read_timeout(Some(timeout))?; + + let request = Request { + id: "autodetect:server:status".into(), + method: Method::Ping(PingParams::default()), + }; + stream.write_all(serde_json::to_string(&request)?.as_bytes())?; + stream.write_all(b"\n")?; + stream.flush()?; + + let mut reader = BufReader::new(stream); + let mut line = String::new(); + let read = reader.read_line(&mut line)?; + if read == 0 || line.trim().is_empty() { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "empty server status response", + )); + } + + let response: serde_json::Value = serde_json::from_str(&line).map_err(io::Error::other)?; + if response.get("error").is_some() { + return Err(io::Error::other(format!( + "server status request failed: {response}" + ))); + } + + let result = &response["result"]; + Ok(Some(ServerStatus { + version: result + .get("version") + .and_then(|value| value.as_str()) + .map(str::to_owned), + protocol: result + .get("protocol") + .and_then(|value| value.as_u64()) + .and_then(|value| u32::try_from(value).ok()), + })) +} + +fn read_server_status() -> io::Result<Option<ServerStatus>> { + read_server_status_at(&crate::api::socket_path(), STATUS_REQUEST_TIMEOUT) +} + +fn validate_running_server_compatibility() -> io::Result<()> { + let Some(status) = read_server_status()? else { + return Err(io::Error::other( + "a herdr server is listening, but its status API is unavailable. Try `herdr server stop`; if that fails, stop the old server process manually, then run `herdr` again.", + )); + }; + + if status.protocol == Some(crate::server::protocol::PROTOCOL_VERSION) { + return Ok(()); + } + + Err(io::Error::other(format!( + "herdr server is running from v{} / protocol {}, but this client is v{} / protocol {}.\nStop the old server with `herdr server stop`, then run `herdr` again.", + status.version.as_deref().unwrap_or("unknown"), + status + .protocol + .map(|value| value.to_string()) + .unwrap_or_else(|| "unknown".to_string()), + env!("CARGO_PKG_VERSION"), + crate::server::protocol::PROTOCOL_VERSION + ))) +} + // --------------------------------------------------------------------------- // Server spawning // --------------------------------------------------------------------------- @@ -183,6 +285,7 @@ pub fn auto_detect_launch() -> io::Result<()> { info!(path = %socket_path.display(), "auto-detect launch starting"); if is_server_listening_at(&socket_path) { + validate_running_server_compatibility()?; info!("server already running, attaching as client"); } else { info!("no server running, spawning server daemon"); @@ -345,4 +448,52 @@ mod tests { assert!(result.is_ok()); let _ = std::fs::remove_dir_all(dir); } + + #[test] + fn read_server_status_at_reads_ping_response() { + let dir = unique_test_dir("status"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("api.sock"); + let listener = UnixListener::bind(&path).unwrap(); + let handle = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = String::new(); + BufReader::new(stream.try_clone().unwrap()) + .read_line(&mut request) + .unwrap(); + assert!(request.contains("ping")); + stream + .write_all( + b"{\"id\":\"autodetect:server:status\",\"result\":{\"type\":\"pong\",\"version\":\"0.5.5\",\"protocol\":2}}\n", + ) + .unwrap(); + stream.flush().unwrap(); + }); + + let status = read_server_status_at(&path, Duration::from_millis(200)) + .unwrap() + .unwrap(); + let _ = handle.join(); + assert_eq!(status.version.as_deref(), Some("0.5.5")); + assert_eq!(status.protocol, Some(2)); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn validate_running_server_compatibility_fails_when_status_api_missing() { + let _guard = env_lock().lock().unwrap(); + let dir = unique_test_dir("missing-api"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("api.sock"); + std::env::set_var(crate::api::SOCKET_PATH_ENV_VAR, &path); + + let err = validate_running_server_compatibility().unwrap_err(); + + assert!( + err.to_string().contains("status API is unavailable"), + "unexpected error: {err}" + ); + std::env::remove_var(crate::api::SOCKET_PATH_ENV_VAR); + let _ = std::fs::remove_dir_all(dir); + } } |
