From 3c3f69bc2262d287fe08e2319b8a98a92f95ad09 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Sun, 10 May 2026 14:06:53 +0300 Subject: [PATCH] fix: preflight running servers before update --- scripts/changelog.py | 23 ++- scripts/test_changelog.py | 37 ++++ src/main.rs | 6 +- src/server/autodetect.rs | 153 +++++++++++++++- src/update.rs | 364 +++++++++++++++++++++++++++++++------- website/latest.json | 1 + 6 files changed, 515 insertions(+), 69 deletions(-) diff --git a/scripts/changelog.py b/scripts/changelog.py index 8db0bf97..e930fd82 100644 --- a/scripts/changelog.py +++ b/scripts/changelog.py @@ -17,6 +17,7 @@ SECTION_RE = re.compile(r"^##\s+(?:\[(?P[^\]]+)\]|(?P.+?))\s*$ VERSION_WITH_DATE_RE = re.compile(r"^(?P.+?)\s+-\s+\d{4}-\d{2}-\d{2}$") DEFAULT_RELEASE_REPO = "ogulcancelik/herdr" DEFAULT_LATEST_JSON_PATH = Path("website/latest.json") +PROTOCOL_SOURCE_PATH = Path("src/server/protocol.rs") ASSET_TARGETS = ( "linux-x86_64", "linux-aarch64", @@ -124,12 +125,25 @@ def prepare_release(text: str, version: str, release_date: str) -> str: return rebuilt + "\n" -def build_latest_json(version: str, notes: str, assets: dict[str, str]) -> str: +def read_protocol_version(source_path: Path = PROTOCOL_SOURCE_PATH) -> int: + content = source_path.read_text(encoding="utf-8") + match = re.search(r"pub const PROTOCOL_VERSION: u32 = (\d+);", content) + if not match: + raise ChangelogError(f"could not read PROTOCOL_VERSION from {source_path}") + return int(match.group(1)) + + +def build_latest_json( + version: str, notes: str, assets: dict[str, str], protocol: int | None = None +) -> str: normalized_version = normalize_version(version) normalized_notes = notes.strip() if not normalized_notes: raise ChangelogError("release notes are empty") + if protocol is None: + protocol = read_protocol_version() + missing_targets = [target for target in ASSET_TARGETS if target not in assets] if missing_targets: raise ChangelogError(f"missing asset targets: {', '.join(missing_targets)}") @@ -139,6 +153,7 @@ def build_latest_json(version: str, notes: str, assets: dict[str, str]) -> str: return json.dumps( { "version": normalized_version, + "protocol": protocol, "notes": normalized_notes, "assets": ordered_assets, }, @@ -194,6 +209,7 @@ def manifest_from_release_payload(payload: dict[str, Any], version: str) -> dict return { "version": normalized_version, + "protocol": read_protocol_version(), "notes": notes, "assets": manifest_assets, } @@ -208,6 +224,10 @@ def canonicalize_manifest(manifest: dict[str, Any], label: str) -> dict[str, Any if not isinstance(notes, str) or not notes.strip(): raise ChangelogError(f"{label} is missing non-empty release notes") + protocol = manifest.get("protocol") + if not isinstance(protocol, int): + raise ChangelogError(f"{label} is missing an integer protocol") + assets = manifest.get("assets") if not isinstance(assets, dict): raise ChangelogError(f"{label} is missing an assets object") @@ -221,6 +241,7 @@ def canonicalize_manifest(manifest: dict[str, Any], label: str) -> dict[str, Any return { "version": normalize_version(version), + "protocol": protocol, "notes": notes.strip(), "assets": normalized_assets, } diff --git a/scripts/test_changelog.py b/scripts/test_changelog.py index 10af5c47..5778b6ac 100644 --- a/scripts/test_changelog.py +++ b/scripts/test_changelog.py @@ -13,6 +13,7 @@ from scripts.changelog import ( extract_section_body, manifest_from_release_payload, prepare_release, + read_protocol_version, ) @@ -50,6 +51,7 @@ class ChangelogScriptTests(unittest.TestCase): ) ) + self.assertEqual(manifest["protocol"], read_protocol_version()) self.assertEqual(manifest["notes"], "### Fixed\n- One") def test_build_latest_json_embeds_notes_and_release_assets(self) -> None: @@ -62,6 +64,7 @@ class ChangelogScriptTests(unittest.TestCase): ) self.assertEqual(manifest["version"], "0.1.1") + self.assertEqual(manifest["protocol"], read_protocol_version()) self.assertEqual(manifest["notes"], "### Fixed\n- Smoothed Claude flapping.") self.assertEqual( manifest["assets"], @@ -94,6 +97,7 @@ class ChangelogScriptTests(unittest.TestCase): manifest, { "version": "0.1.1", + "protocol": read_protocol_version(), "notes": "### Fixed\n- One", "assets": { "linux-x86_64": "https://example.com/linux-x86_64", @@ -136,6 +140,7 @@ class ChangelogScriptTests(unittest.TestCase): canonicalize_manifest( { "version": "0.1.1", + "protocol": read_protocol_version(), "notes": "### Fixed\n- One", "assets": { "linux-x86_64": "https://example.com/linux-x86_64", @@ -149,6 +154,7 @@ class ChangelogScriptTests(unittest.TestCase): def test_ensure_manifest_matches_expected_normalizes_whitespace(self) -> None: actual = { "version": "v0.1.1", + "protocol": read_protocol_version(), "notes": "\n### Fixed\n- One\n", "assets": { "linux-x86_64": " https://example.com/linux-x86_64 ", @@ -159,6 +165,7 @@ class ChangelogScriptTests(unittest.TestCase): } expected = { "version": "0.1.1", + "protocol": read_protocol_version(), "notes": "### Fixed\n- One", "assets": { "linux-x86_64": "https://example.com/linux-x86_64", @@ -176,6 +183,7 @@ class ChangelogScriptTests(unittest.TestCase): ensure_manifest_matches_expected( { "version": "0.1.1", + "protocol": read_protocol_version(), "notes": "### Fixed\n- Different", "assets": { "linux-x86_64": "https://example.com/linux-x86_64", @@ -186,6 +194,7 @@ class ChangelogScriptTests(unittest.TestCase): }, { "version": "0.1.1", + "protocol": read_protocol_version(), "notes": "### Fixed\n- One", "assets": { "linux-x86_64": "https://example.com/linux-x86_64", @@ -197,6 +206,34 @@ class ChangelogScriptTests(unittest.TestCase): "test manifest", ) + def test_canonicalize_manifest_requires_protocol(self) -> None: + with self.assertRaisesRegex(ChangelogError, "missing an integer protocol"): + canonicalize_manifest( + { + "version": "0.1.1", + "notes": "### Fixed\n- One", + "assets": default_release_assets("0.1.1"), + }, + "test manifest", + ) + + def test_ensure_manifest_matches_expected_rejects_different_protocol(self) -> None: + actual = { + "version": "0.1.1", + "protocol": read_protocol_version() + 1, + "notes": "### Fixed\n- One", + "assets": default_release_assets("0.1.1"), + } + expected = { + "version": "0.1.1", + "protocol": read_protocol_version(), + "notes": "### Fixed\n- One", + "assets": default_release_assets("0.1.1"), + } + + with self.assertRaisesRegex(ChangelogError, "does not match"): + ensure_manifest_matches_expected(actual, expected, "test manifest") + if __name__ == "__main__": unittest.main() diff --git a/src/main.rs b/src/main.rs index 2b8c76b6..851ff8f9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -383,7 +383,11 @@ fn main() -> io::Result<()> { // Auto-detect launch: when --no-session is NOT set, use server/client mode. // Check if a server is running, spawn one if needed, then attach as client. if !no_session { - return server::autodetect::auto_detect_launch(); + if let Err(err) = server::autodetect::auto_detect_launch() { + eprintln!("herdr: {err}"); + std::process::exit(1); + } + return Ok(()); } // --- Monolithic mode (--no-session escape hatch) --- 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, + protocol: Option, +} + /// 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> { + 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> { + 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); + } } diff --git a/src/update.rs b/src/update.rs index 6a0b586b..070b8fda 100644 --- a/src/update.rs +++ b/src/update.rs @@ -86,6 +86,8 @@ impl std::fmt::Display for Version { #[derive(Deserialize)] struct UpdateManifest { version: String, + /// Thin-client protocol spoken by this release, when advertised by the manifest. + protocol: Option, notes: String, assets: BTreeMap, } @@ -105,8 +107,10 @@ impl UpdateManifest { // --------------------------------------------------------------------------- /// Information about an available update. +#[derive(Debug, Clone)] struct ReleaseInfo { version: Version, + target_protocol: Option, download_url: String, notes_body: String, } @@ -155,6 +159,7 @@ fn check_latest() -> Result, String> { Ok(Some(ReleaseInfo { version: latest, + target_protocol: manifest.protocol, download_url, notes_body, })) @@ -243,6 +248,234 @@ fn api_server_is_running() -> bool { api_server_is_running_at(&crate::api::socket_path()) } +fn client_protocol_server_is_running_at(socket_path: &Path) -> bool { + if !socket_path.exists() { + return false; + } + + UnixStream::connect(socket_path).is_ok() +} + +fn client_protocol_server_is_running() -> bool { + client_protocol_server_is_running_at(&crate::server::headless::client_socket_path()) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct RunningServerInfo { + version: Option, + protocol: Option, +} + +fn read_running_server_info_at( + socket_path: &Path, + timeout: Duration, +) -> Result, String> { + 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(format!( + "failed to connect to running server on {}: {err}", + socket_path.display() + )); + } + }; + + stream + .set_write_timeout(Some(timeout)) + .map_err(|e| format!("failed to set server status write timeout: {e}"))?; + stream + .set_read_timeout(Some(timeout)) + .map_err(|e| format!("failed to set server status read timeout: {e}"))?; + + let request = Request { + id: "update:server:status".into(), + method: Method::Ping(PingParams::default()), + }; + stream + .write_all( + serde_json::to_string(&request) + .map_err(|e| e.to_string())? + .as_bytes(), + ) + .map_err(|e| format!("failed to send server status request: {e}"))?; + stream + .write_all(b"\n") + .map_err(|e| format!("failed to finish server status request: {e}"))?; + stream + .flush() + .map_err(|e| format!("failed to flush server status 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 status response: {e}"))?; + if read == 0 || line.trim().is_empty() { + return Err("empty server status response".into()); + } + + let response: serde_json::Value = + serde_json::from_str(&line).map_err(|e| format!("invalid server status response: {e}"))?; + if let Some(error) = response.get("error") { + return Err(format!("server status request failed: {error}")); + } + + let result = &response["result"]; + Ok(Some(RunningServerInfo { + 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_running_server_info() -> Result, String> { + read_running_server_info_at(&crate::api::socket_path(), SERVER_STOP_RESPONSE_TIMEOUT) +} + +fn protocol_label(protocol: Option) -> String { + protocol + .map(|value| value.to_string()) + .unwrap_or_else(|| "unknown".to_string()) +} + +fn version_label(version: Option<&str>) -> &str { + version.unwrap_or("unknown") +} + +fn update_requires_server_stop(server: &RunningServerInfo, release: &ReleaseInfo) -> bool { + match (server.protocol, release.target_protocol) { + (Some(server_protocol), Some(target_protocol)) => server_protocol != target_protocol, + _ => true, + } +} + +fn parse_stop_server_before_update_response(input: &str) -> Option { + let trimmed = input.trim().to_ascii_lowercase(); + match trimmed.as_str() { + "" | "n" | "no" => Some(false), + "y" | "yes" => Some(true), + _ => None, + } +} + +fn prompt_to_stop_server_before_update( + server: &RunningServerInfo, + release: &ReleaseInfo, + requires_stop: bool, +) -> Result { + if !io::stdin().is_terminal() { + if requires_stop { + return Err(format!( + "a herdr server is running and updating to v{} requires stopping it; run `herdr server stop`, then run `herdr update` again", + release.version + )); + } + + eprintln!( + "a herdr server is running. updating the binary will not affect that server until it restarts." + ); + return Ok(false); + } + + eprintln!("a herdr server is currently running:"); + eprintln!( + " server: v{} protocol {}", + version_label(server.version.as_deref()), + protocol_label(server.protocol) + ); + eprintln!( + " update: v{} protocol {}", + release.version, + protocol_label(release.target_protocol) + ); + eprintln!(); + + if requires_stop { + eprintln!( + "this update changes the herdr client/server protocol. the running server must be stopped before the new client can attach." + ); + eprintln!("stopping the server will end the current herdr session and its panes."); + } else { + eprintln!("updating the binary will not affect the running server until it restarts."); + } + + loop { + let prompt = if requires_stop { + "stop the server and continue updating? [y/N] " + } else { + "stop the server before updating? [y/N] " + }; + eprint!("{prompt}"); + 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_before_update_response(&input) { + return Ok(answer); + } + + eprintln!("please answer y or n"); + } +} + +fn preflight_running_server_for_update(release: &ReleaseInfo) -> Result { + let Some(server) = read_running_server_info()? else { + if client_protocol_server_is_running() { + return Err( + "a herdr server is listening, but its status API is unavailable; try `herdr server stop`, or stop the old server process manually, then run `herdr update` again" + .to_string(), + ); + } + return Ok(false); + }; + + let requires_stop = update_requires_server_stop(&server, release); + let stop_server = prompt_to_stop_server_before_update(&server, release, requires_stop)?; + if !stop_server { + if requires_stop { + return Err( + "update cancelled; stop the running herdr server with `herdr server stop`, then run `herdr update` again" + .to_string(), + ); + } + return Ok(false); + } + + stop_server_via_api()?; + wait_for_server_shutdown(SERVER_SHUTDOWN_CONFIRM_TIMEOUT)?; + eprintln!("stopped the running herdr server."); + Ok(true) +} + fn stop_server_via_api_at(socket_path: &Path, timeout: Duration) -> Result<(), String> { use crate::api::schema::{EmptyParams, Method, Request}; @@ -339,45 +572,6 @@ 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 { - 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 { - 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 // --------------------------------------------------------------------------- @@ -400,6 +594,8 @@ pub fn self_update() -> Result { } }; + let stopped_server = preflight_running_server_for_update(&release)?; + eprintln!("downloading v{}...", release.version); if let Err(e) = crate::release_notes::save_pending(&release.version.to_string(), &release.notes_body) @@ -409,26 +605,12 @@ pub fn self_update() -> Result { 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." - ); - } + if stopped_server { + eprintln!("run herdr again to start the updated server."); + } else if api_server_is_running() { + eprintln!("the running herdr server will use the new version after it restarts."); } else { - eprintln!("update installed. run herdr again."); + eprintln!("run herdr again."); } Ok(release.version) @@ -619,14 +801,58 @@ mod tests { } #[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); + fn parse_stop_server_before_update_response_defaults_no_for_blank() { + assert_eq!(parse_stop_server_before_update_response(""), Some(false)); + assert_eq!(parse_stop_server_before_update_response("\n"), Some(false)); + assert_eq!(parse_stop_server_before_update_response("n"), Some(false)); + assert_eq!(parse_stop_server_before_update_response("no"), Some(false)); + assert_eq!(parse_stop_server_before_update_response("y"), Some(true)); + assert_eq!(parse_stop_server_before_update_response("yes"), Some(true)); + assert_eq!(parse_stop_server_before_update_response("later"), None); + } + + #[test] + fn update_requires_server_stop_when_target_protocol_differs_or_unknown() { + let server = RunningServerInfo { + version: Some("0.5.5".to_string()), + protocol: Some(2), + }; + let compatible_release = ReleaseInfo { + version: Version::parse("0.5.6").unwrap(), + target_protocol: Some(2), + download_url: "https://example.com/herdr".to_string(), + notes_body: "### Changed\n- One".to_string(), + }; + let incompatible_release = ReleaseInfo { + target_protocol: Some(4), + ..compatible_release.clone() + }; + let unknown_release = ReleaseInfo { + target_protocol: None, + ..compatible_release.clone() + }; + + assert!(!update_requires_server_stop(&server, &compatible_release)); + assert!(update_requires_server_stop(&server, &incompatible_release)); + assert!(update_requires_server_stop(&server, &unknown_release)); + } + + #[test] + fn client_protocol_server_is_running_at_detects_live_socket() { + let socket_path = unique_test_socket_path("client-live"); + let listener = UnixListener::bind(&socket_path).unwrap(); + + assert!(client_protocol_server_is_running_at(&socket_path)); + + drop(listener); + let _ = fs::remove_file(&socket_path); + } + + #[test] + fn client_protocol_server_is_running_at_ignores_missing_socket() { + let socket_path = unique_test_socket_path("client-missing"); + + assert!(!client_protocol_server_is_running_at(&socket_path)); } #[test] @@ -750,6 +976,7 @@ mod tests { fn update_manifest_deserializes() { let json = "{\n\ \"version\": \"0.2.0\",\n\ + \"protocol\": 4,\n\ \"notes\": \"### Changed\\n- One\",\n\ \"assets\": {\n\ \"linux-x86_64\": \"https://example.com/herdr-linux-x86_64\",\n\ @@ -758,6 +985,7 @@ mod tests { }"; let manifest: UpdateManifest = serde_json::from_str(json).unwrap(); assert_eq!(manifest.version, "0.2.0"); + assert_eq!(manifest.protocol, Some(4)); assert_eq!(manifest.assets.len(), 2); assert_eq!(manifest.notes_body(), "### Changed\n- One"); assert_eq!( @@ -784,6 +1012,10 @@ mod tests { .expect("website/latest.json should match updater schema"); assert!(!manifest.notes_body().is_empty()); + assert_eq!( + manifest.protocol, + Some(crate::server::protocol::PROTOCOL_VERSION) + ); assert_eq!(manifest.assets.len(), 4); for target in [ diff --git a/website/latest.json b/website/latest.json index 4d5c7bed..1c0f151f 100644 --- a/website/latest.json +++ b/website/latest.json @@ -1,5 +1,6 @@ { "version": "0.5.6", + "protocol": 4, "notes": "### Added\n- Added the `vesper` built-in theme. (#71, thanks @nexxeln)\n- Added `herdr --remote `, so you can use Herdr as a thin client for remote servers without SSHing in first. Herdr connects over SSH, bootstraps a matching remote `herdr` binary when needed, starts the remote server automatically, and streams an efficient terminal view back to your local terminal.\n\n### Changed\n- Updated the bundled `libghostty-vt` engine and removed the custom Linux C++ runtime link workaround from static builds.\n- CLI workspace, tab, and pane creation now preserve the current focus by default; pass `--focus` to switch to the newly created item.\n\n### Fixed\n- OSC 8 hyperlinks emitted inside panes now remain clickable after Herdr renders them, including titled markdown-style links.\n- Agent panel scope now defaults to `all` and is saved to config when changed, so choosing `current` or `all` survives session resets and upgrades.\n- Native agent hook state now clears when the detected native agent exits, preventing stale hook-reported status from sticking to a pane.\n- Clicking an in-app agent toast now jumps to the relevant pane and clears the toast after focus.", "assets": { "linux-x86_64": "https://github.com/ogulcancelik/herdr/releases/download/v0.5.6/herdr-linux-x86_64",