fix: preflight running servers before update
This commit is contained in:
parent
70d217a2a8
commit
3c3f69bc22
|
|
@ -17,6 +17,7 @@ SECTION_RE = re.compile(r"^##\s+(?:\[(?P<bracketed>[^\]]+)\]|(?P<plain>.+?))\s*$
|
|||
VERSION_WITH_DATE_RE = re.compile(r"^(?P<version>.+?)\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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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) ---
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
364
src/update.rs
364
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<u32>,
|
||||
notes: String,
|
||||
assets: BTreeMap<String, String>,
|
||||
}
|
||||
|
|
@ -105,8 +107,10 @@ impl UpdateManifest {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Information about an available update.
|
||||
#[derive(Debug, Clone)]
|
||||
struct ReleaseInfo {
|
||||
version: Version,
|
||||
target_protocol: Option<u32>,
|
||||
download_url: String,
|
||||
notes_body: String,
|
||||
}
|
||||
|
|
@ -155,6 +159,7 @@ fn check_latest() -> Result<Option<ReleaseInfo>, 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<String>,
|
||||
protocol: Option<u32>,
|
||||
}
|
||||
|
||||
fn read_running_server_info_at(
|
||||
socket_path: &Path,
|
||||
timeout: Duration,
|
||||
) -> Result<Option<RunningServerInfo>, 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<Option<RunningServerInfo>, String> {
|
||||
read_running_server_info_at(&crate::api::socket_path(), SERVER_STOP_RESPONSE_TIMEOUT)
|
||||
}
|
||||
|
||||
fn protocol_label(protocol: Option<u32>) -> 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<bool> {
|
||||
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<bool, String> {
|
||||
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<bool, String> {
|
||||
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<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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -400,6 +594,8 @@ pub fn self_update() -> Result<Version, String> {
|
|||
}
|
||||
};
|
||||
|
||||
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<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."
|
||||
);
|
||||
}
|
||||
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 [
|
||||
|
|
|
|||
|
|
@ -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 <ssh-target>`, 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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue