aboutsummaryrefslogtreecommitdiff
path: root/src/update.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/update.rs')
-rw-r--r--src/update.rs329
1 files changed, 328 insertions, 1 deletions
diff --git a/src/update.rs b/src/update.rs
index af446fed..ed592ac7 100644
--- a/src/update.rs
+++ b/src/update.rs
@@ -9,7 +9,11 @@
use std::collections::BTreeMap;
use std::env;
use std::fs;
+use std::io::{self, BufRead, BufReader, IsTerminal, Write};
+use std::os::unix::net::UnixStream;
+use std::path::Path;
use std::process::Command;
+use std::time::{Duration, Instant};
use serde::Deserialize;
@@ -18,6 +22,9 @@ const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
const FAKE_UPDATE_VERSION_ENV: &str = "HERDR_FAKE_UPDATE_VERSION";
const FAKE_UPDATE_NOTES_VERSION_ENV: &str = "HERDR_FAKE_UPDATE_NOTES_VERSION";
const DEFAULT_FAKE_UPDATE_NOTES_VERSION: &str = "0.3.0";
+const SERVER_STOP_RESPONSE_TIMEOUT: Duration = Duration::from_secs(5);
+const SERVER_SHUTDOWN_CONFIRM_TIMEOUT: Duration = Duration::from_secs(5);
+const SERVER_SHUTDOWN_POLL_INTERVAL: Duration = Duration::from_millis(100);
fn fake_release_notes_body(version: &str) -> String {
let notes_version = env::var(FAKE_UPDATE_NOTES_VERSION_ENV)
@@ -213,11 +220,174 @@ fn download_and_install(release: &ReleaseInfo) -> Result<(), String> {
}
// ---------------------------------------------------------------------------
+// Upgrade flow helpers
+// ---------------------------------------------------------------------------
+
+fn running_inside_herdr_env(herdr_env: Option<&str>) -> bool {
+ herdr_env == Some(crate::HERDR_ENV_VALUE)
+}
+
+fn running_inside_herdr() -> bool {
+ running_inside_herdr_env(env::var(crate::HERDR_ENV_VAR).ok().as_deref())
+}
+
+fn api_server_is_running_at(socket_path: &Path) -> bool {
+ if !socket_path.exists() {
+ return false;
+ }
+
+ UnixStream::connect(socket_path).is_ok()
+}
+
+fn api_server_is_running() -> bool {
+ api_server_is_running_at(&crate::api::socket_path())
+}
+
+fn stop_server_via_api_at(socket_path: &Path, timeout: Duration) -> Result<(), String> {
+ use crate::api::schema::{EmptyParams, Method, Request};
+
+ let request = Request {
+ id: "update:server:stop".into(),
+ method: Method::ServerStop(EmptyParams::default()),
+ };
+
+ let mut stream = UnixStream::connect(socket_path)
+ .map_err(|e| format!("failed to connect to running server: {e}"))?;
+ stream
+ .set_write_timeout(Some(timeout))
+ .map_err(|e| format!("failed to set server stop write timeout: {e}"))?;
+ stream
+ .set_read_timeout(Some(timeout))
+ .map_err(|e| format!("failed to set server stop read timeout: {e}"))?;
+ stream
+ .write_all(
+ serde_json::to_string(&request)
+ .map_err(|e| e.to_string())?
+ .as_bytes(),
+ )
+ .map_err(|e| format!("failed to send server stop request: {e}"))?;
+ stream
+ .write_all(b"\n")
+ .map_err(|e| format!("failed to finish server stop request: {e}"))?;
+ stream
+ .flush()
+ .map_err(|e| format!("failed to flush server stop request: {e}"))?;
+
+ let mut reader = BufReader::new(stream);
+ let mut line = String::new();
+ let read = reader
+ .read_line(&mut line)
+ .map_err(|e| format!("failed to read server stop response: {e}"))?;
+ if read == 0 || line.trim().is_empty() {
+ return Err("empty server stop response".into());
+ }
+ let response: serde_json::Value =
+ serde_json::from_str(&line).map_err(|e| format!("invalid server response: {e}"))?;
+ if let Some(error) = response.get("error") {
+ return Err(format!("server stop failed: {error}"));
+ }
+
+ Ok(())
+}
+
+fn stop_server_via_api() -> Result<(), String> {
+ stop_server_via_api_at(&crate::api::socket_path(), SERVER_STOP_RESPONSE_TIMEOUT)
+}
+
+fn server_shutdown_confirmed_at(socket_path: &Path) -> Result<bool, String> {
+ if !socket_path.exists() {
+ return Ok(true);
+ }
+
+ match UnixStream::connect(socket_path) {
+ Ok(_) => Ok(false),
+ Err(err)
+ if matches!(
+ err.kind(),
+ io::ErrorKind::ConnectionRefused
+ | io::ErrorKind::NotFound
+ | io::ErrorKind::TimedOut
+ ) =>
+ {
+ Ok(true)
+ }
+ Err(err) => Err(format!(
+ "failed to confirm whether the old server stopped on {}: {err}",
+ socket_path.display()
+ )),
+ }
+}
+
+fn wait_for_server_shutdown_at(socket_path: &Path, timeout: Duration) -> Result<(), String> {
+ let deadline = Instant::now() + timeout;
+ loop {
+ if server_shutdown_confirmed_at(socket_path)? {
+ return Ok(());
+ }
+ if Instant::now() >= deadline {
+ return Err(format!(
+ "shutdown was requested, but the old server is still responding on {} after {} seconds",
+ socket_path.display(),
+ timeout.as_secs()
+ ));
+ }
+ std::thread::sleep(SERVER_SHUTDOWN_POLL_INTERVAL);
+ }
+}
+
+fn wait_for_server_shutdown(timeout: Duration) -> Result<(), String> {
+ wait_for_server_shutdown_at(&crate::api::socket_path(), timeout)
+}
+
+fn parse_stop_server_response(input: &str) -> Option<bool> {
+ let trimmed = input.trim().to_ascii_lowercase();
+ match trimmed.as_str() {
+ "" | "y" | "yes" => Some(true),
+ "n" | "no" => Some(false),
+ _ => None,
+ }
+}
+
+fn prompt_to_stop_running_server() -> Result<bool, String> {
+ if !io::stdin().is_terminal() {
+ eprintln!(
+ "a herdr server is still running on the old version. rerun `herdr update` interactively or stop it later with `herdr server stop`."
+ );
+ return Ok(false);
+ }
+
+ loop {
+ eprint!("a herdr server is still running on the old version. stop it now to apply the update? [Y/n] ");
+ io::stderr()
+ .flush()
+ .map_err(|e| format!("failed to flush prompt: {e}"))?;
+
+ let mut input = String::new();
+ let read = io::stdin()
+ .read_line(&mut input)
+ .map_err(|e| format!("failed to read prompt response: {e}"))?;
+ if read == 0 {
+ return Ok(false);
+ }
+
+ if let Some(answer) = parse_stop_server_response(&input) {
+ return Ok(answer);
+ }
+
+ eprintln!("please answer y or n");
+ }
+}
+
+// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/// Manual self-update command (`herdr update`).
pub fn self_update() -> Result<Version, String> {
+ if running_inside_herdr() {
+ return Err("run `herdr update` outside herdr after detaching from the session".into());
+ }
+
eprintln!("checking for updates...");
let current = Version::current();
@@ -239,6 +409,28 @@ pub fn self_update() -> Result<Version, String> {
download_and_install(&release)?;
eprintln!("updated to v{}", release.version);
+ if api_server_is_running() {
+ if prompt_to_stop_running_server()? {
+ match stop_server_via_api() {
+ Ok(()) => match wait_for_server_shutdown(SERVER_SHUTDOWN_CONFIRM_TIMEOUT) {
+ Ok(()) => eprintln!("stopped the running herdr server. run herdr again."),
+ Err(err) => eprintln!(
+ "update installed, but {err}\ntry `herdr server stop` from another shell, then run `herdr` again."
+ ),
+ },
+ Err(err) => eprintln!(
+ "update installed, but failed to stop the running server: {err}\ntry `herdr server stop` from another shell, then run `herdr` again."
+ ),
+ }
+ } else {
+ eprintln!(
+ "update installed, but the old server is still running. restart it later to use the new version."
+ );
+ }
+ } else {
+ eprintln!("update installed. run herdr again.");
+ }
+
Ok(release.version)
}
@@ -283,7 +475,7 @@ pub fn auto_update(events: tokio::sync::mpsc::Sender<crate::events::AppEvent>) {
}
tracing::info!(
- "auto-update check: v{} available, waiting for manual install",
+ "auto-update check: v{} available, waiting for explicit install",
release.version
);
@@ -324,6 +516,42 @@ fn platform_target() -> (&'static str, &'static str) {
#[cfg(test)]
mod tests {
use super::*;
+ use std::os::unix::net::UnixListener;
+ use std::sync::{
+ atomic::{AtomicBool, Ordering},
+ Arc,
+ };
+ use std::thread;
+
+ fn unique_test_socket_path(name: &str) -> std::path::PathBuf {
+ let nanos = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap()
+ .as_nanos();
+ std::env::temp_dir().join(format!(
+ "herdr-update-{name}-{}-{nanos}.sock",
+ std::process::id()
+ ))
+ }
+
+ fn spawn_accept_loop(path: &Path) -> (Arc<AtomicBool>, thread::JoinHandle<()>) {
+ let listener = UnixListener::bind(path).unwrap();
+ listener.set_nonblocking(true).unwrap();
+ let running = Arc::new(AtomicBool::new(true));
+ let running_thread = Arc::clone(&running);
+ let handle = thread::spawn(move || {
+ while running_thread.load(Ordering::Relaxed) {
+ match listener.accept() {
+ Ok((_stream, _)) => {}
+ Err(err) if err.kind() == io::ErrorKind::WouldBlock => {
+ thread::sleep(Duration::from_millis(10));
+ }
+ Err(_) => break,
+ }
+ }
+ });
+ (running, handle)
+ }
#[test]
fn parse_version_basic() {
@@ -378,6 +606,105 @@ mod tests {
}
#[test]
+ fn running_inside_herdr_env_requires_marker() {
+ assert!(running_inside_herdr_env(Some(crate::HERDR_ENV_VALUE)));
+ assert!(!running_inside_herdr_env(None));
+ assert!(!running_inside_herdr_env(Some("0")));
+ }
+
+ #[test]
+ fn parse_stop_server_response_defaults_yes_for_blank() {
+ assert_eq!(parse_stop_server_response(""), Some(true));
+ assert_eq!(parse_stop_server_response("\n"), Some(true));
+ assert_eq!(parse_stop_server_response("y"), Some(true));
+ assert_eq!(parse_stop_server_response("yes"), Some(true));
+ assert_eq!(parse_stop_server_response("n"), Some(false));
+ assert_eq!(parse_stop_server_response("no"), Some(false));
+ assert_eq!(parse_stop_server_response("later"), None);
+ }
+
+ #[test]
+ fn stop_server_via_api_accepts_success_response() {
+ let socket_path = unique_test_socket_path("stop-ok");
+ let listener = UnixListener::bind(&socket_path).unwrap();
+ let handle = 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("server.stop")
+ || request.contains("ServerStop")
+ || request.contains("server_stop")
+ || request.contains("ServerStop")
+ );
+ stream
+ .write_all(b"{\"id\":\"update:server:stop\",\"result\":{}}\n")
+ .unwrap();
+ stream.flush().unwrap();
+ });
+
+ let result = stop_server_via_api_at(&socket_path, Duration::from_millis(200));
+ let _ = handle.join();
+ let _ = fs::remove_file(&socket_path);
+ assert!(
+ result.is_ok(),
+ "expected stop request to succeed: {result:?}"
+ );
+ }
+
+ #[test]
+ fn stop_server_via_api_times_out_when_server_never_replies() {
+ let socket_path = unique_test_socket_path("stop-timeout");
+ let listener = UnixListener::bind(&socket_path).unwrap();
+ let handle = thread::spawn(move || {
+ let (_stream, _) = listener.accept().unwrap();
+ thread::sleep(Duration::from_millis(200));
+ });
+
+ let err = stop_server_via_api_at(&socket_path, Duration::from_millis(50)).unwrap_err();
+ let _ = handle.join();
+ let _ = fs::remove_file(&socket_path);
+ assert!(
+ err.contains("failed to read server stop response")
+ || err.contains("empty server stop response"),
+ "unexpected error: {err}"
+ );
+ }
+
+ #[test]
+ fn wait_for_server_shutdown_succeeds_once_socket_stops_accepting() {
+ let socket_path = unique_test_socket_path("shutdown-ok");
+ let (running, handle) = spawn_accept_loop(&socket_path);
+ let running_for_stop = Arc::clone(&running);
+ let stopper = thread::spawn(move || {
+ thread::sleep(Duration::from_millis(60));
+ running_for_stop.store(false, Ordering::Relaxed);
+ });
+
+ let result = wait_for_server_shutdown_at(&socket_path, Duration::from_millis(500));
+ running.store(false, Ordering::Relaxed);
+ let _ = stopper.join();
+ let _ = handle.join();
+ let _ = fs::remove_file(&socket_path);
+ assert!(result.is_ok(), "expected shutdown confirmation: {result:?}");
+ }
+
+ #[test]
+ fn wait_for_server_shutdown_times_out_while_socket_keeps_responding() {
+ let socket_path = unique_test_socket_path("shutdown-timeout");
+ let (running, handle) = spawn_accept_loop(&socket_path);
+
+ let err =
+ wait_for_server_shutdown_at(&socket_path, Duration::from_millis(120)).unwrap_err();
+ running.store(false, Ordering::Relaxed);
+ let _ = handle.join();
+ let _ = fs::remove_file(&socket_path);
+ assert!(err.contains("still responding"), "unexpected error: {err}");
+ }
+
+ #[test]
fn version_ordering() {
let v010 = Version::parse("0.1.0").unwrap();
let v011 = Version::parse("0.1.1").unwrap();